ltcai 9.6.0 → 9.7.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 +52 -42
- package/auto_setup.py +11 -1
- package/docs/CHANGELOG.md +54 -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 +211 -7
- package/lattice_brain/graph/retrieval_vector.py +102 -0
- package/lattice_brain/ingestion.py +360 -4
- 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 +27 -2
- package/latticeai/api/change_proposals.py +32 -3
- 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 +155 -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/brain_intelligence.py +97 -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 +10 -10
- package/static/app/assets/{Act-BkOEmwBi.js → Act-B6c39ays.js} +2 -1
- package/static/app/assets/{Brain-C9ITUsQ_.js → Brain-D7Qg4k6M.js} +1 -1
- package/static/app/assets/{Capture-C-ppTeud.js → Capture-VF_di68r.js} +1 -1
- package/static/app/assets/{Library-CGQbgWTu.js → Library-D_Gis2PA.js} +1 -1
- package/static/app/assets/{System-djmj0n2_.js → System-C5s5H2ov.js} +1 -1
- package/static/app/assets/{index-AF0-4XVv.js → index-DJC_2oub.js} +3 -3
- package/static/app/assets/{primitives-jbb2qv4Q.js → primitives-DL4Nip8C.js} +1 -1
- package/static/app/assets/{textarea-CFoo0OxJ.js → textarea-woZfCXHy.js} +1 -1
- package/static/app/index.html +1 -1
- package/static/sw.js +1 -1
|
@@ -15,14 +15,36 @@ 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
|
|
23
45
|
from dataclasses import dataclass, field
|
|
24
46
|
from pathlib import Path
|
|
25
|
-
from typing import Any, Dict, List, Optional
|
|
47
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
26
48
|
|
|
27
49
|
from .runtime.hooks import dispatch_tool
|
|
28
50
|
from .utils import utc_now_iso
|
|
@@ -45,6 +67,92 @@ _MEMORY_NODE_TYPES = {"decision": "Decision", "experience": "Experience", "works
|
|
|
45
67
|
|
|
46
68
|
DEFAULT_MAX_TEXT_BYTES = 5 * 1024 * 1024 # 5 MB of extracted text per item
|
|
47
69
|
|
|
70
|
+
# ── Folder ingestion (ingest_folder) filters ─────────────────────────────────
|
|
71
|
+
# Directories that are always pruned regardless of .latticeignore.
|
|
72
|
+
FOLDER_DEFAULT_SKIP_DIRS = frozenset(
|
|
73
|
+
{
|
|
74
|
+
".git",
|
|
75
|
+
"node_modules",
|
|
76
|
+
"__pycache__",
|
|
77
|
+
".venv",
|
|
78
|
+
"venv",
|
|
79
|
+
"env",
|
|
80
|
+
".pytest_cache",
|
|
81
|
+
".mypy_cache",
|
|
82
|
+
".ruff_cache",
|
|
83
|
+
"dist",
|
|
84
|
+
"build",
|
|
85
|
+
".next",
|
|
86
|
+
"target",
|
|
87
|
+
".cache",
|
|
88
|
+
".idea",
|
|
89
|
+
".vscode",
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
# Extension filter matching FILE_SOURCE_TYPES conventions: text/markdown/code
|
|
93
|
+
# are read inline (extracted content → chunks); .pdf routes as source_type
|
|
94
|
+
# "pdf" through ingest_document (content extraction is upstream's concern).
|
|
95
|
+
FOLDER_TEXT_EXTENSIONS = frozenset(
|
|
96
|
+
{".txt", ".md", ".markdown", ".rst", ".csv", ".json", ".yaml", ".yml", ".toml", ".ini"}
|
|
97
|
+
)
|
|
98
|
+
FOLDER_CODE_EXTENSIONS = frozenset(
|
|
99
|
+
{
|
|
100
|
+
".py", ".js", ".ts", ".tsx", ".jsx", ".html", ".css", ".go", ".rs",
|
|
101
|
+
".java", ".c", ".h", ".cpp", ".hpp", ".rb", ".php", ".swift", ".kt",
|
|
102
|
+
".sh", ".sql",
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
FOLDER_DOCUMENT_EXTENSIONS = frozenset({".pdf"})
|
|
106
|
+
DEFAULT_FOLDER_EXTENSIONS = (
|
|
107
|
+
FOLDER_TEXT_EXTENSIONS | FOLDER_CODE_EXTENSIONS | FOLDER_DOCUMENT_EXTENSIONS
|
|
108
|
+
)
|
|
109
|
+
DEFAULT_MAX_FILE_BYTES = 4_000_000 # matches the local-index text/code budget
|
|
110
|
+
LATTICEIGNORE_FILENAME = ".latticeignore"
|
|
111
|
+
# Opt-out escape hatch for the post-ingest incremental vector sync.
|
|
112
|
+
AUTO_VECTOR_INDEX_ENV = "LATTICEAI_AUTO_VECTOR_INDEX"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _load_latticeignore(root: Path) -> List[str]:
|
|
116
|
+
"""Parse ``root/.latticeignore`` → glob patterns (gitignore-like subset)."""
|
|
117
|
+
ignore_file = root / LATTICEIGNORE_FILENAME
|
|
118
|
+
patterns: List[str] = []
|
|
119
|
+
if not ignore_file.is_file():
|
|
120
|
+
return patterns
|
|
121
|
+
try:
|
|
122
|
+
lines = ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
123
|
+
except OSError:
|
|
124
|
+
return patterns
|
|
125
|
+
for raw in lines:
|
|
126
|
+
line = raw.strip()
|
|
127
|
+
if not line or line.startswith("#"):
|
|
128
|
+
continue
|
|
129
|
+
patterns.append(line)
|
|
130
|
+
return patterns
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _matches_ignore(
|
|
134
|
+
rel_posix: str, name: str, *, is_dir: bool, patterns: Iterable[str]
|
|
135
|
+
) -> bool:
|
|
136
|
+
"""fnmatch-based .latticeignore matching.
|
|
137
|
+
|
|
138
|
+
- ``pattern/`` matches directories only (files under it never appear
|
|
139
|
+
because ignored directories are pruned during the walk).
|
|
140
|
+
- Patterns match against both the root-relative posix path and the
|
|
141
|
+
basename, so ``*.log`` and ``docs/draft.md`` both behave as expected.
|
|
142
|
+
"""
|
|
143
|
+
for raw in patterns:
|
|
144
|
+
pattern = raw
|
|
145
|
+
if pattern.endswith("/"):
|
|
146
|
+
if not is_dir:
|
|
147
|
+
continue
|
|
148
|
+
pattern = pattern.rstrip("/")
|
|
149
|
+
pattern = pattern.lstrip("/")
|
|
150
|
+
if not pattern:
|
|
151
|
+
continue
|
|
152
|
+
if fnmatch.fnmatch(rel_posix, pattern) or fnmatch.fnmatch(name, pattern):
|
|
153
|
+
return True
|
|
154
|
+
return False
|
|
155
|
+
|
|
48
156
|
|
|
49
157
|
# --- Large candidate 1 slice: incremental / background ingestion support ---
|
|
50
158
|
@dataclass
|
|
@@ -161,6 +269,7 @@ class IngestionPipeline:
|
|
|
161
269
|
max_text_bytes: int = DEFAULT_MAX_TEXT_BYTES,
|
|
162
270
|
pipeline_name: str = "unified-ingestion",
|
|
163
271
|
bg_queue: Optional[BackgroundIngestionQueue] = None,
|
|
272
|
+
auto_vector_index: bool = True,
|
|
164
273
|
) -> None:
|
|
165
274
|
self._kg = knowledge_graph
|
|
166
275
|
self._hooks = hooks
|
|
@@ -169,6 +278,13 @@ class IngestionPipeline:
|
|
|
169
278
|
self._max_text_bytes = int(max_text_bytes)
|
|
170
279
|
self._pipeline_name = pipeline_name
|
|
171
280
|
self._bg_queue = bg_queue or BackgroundIngestionQueue()
|
|
281
|
+
# Incremental vector sync after each successful non-duplicate ingest.
|
|
282
|
+
# Constructor opt-out AND env opt-out (LATTICEAI_AUTO_VECTOR_INDEX=0)
|
|
283
|
+
# both disable it; a vector failure never fails the ingest.
|
|
284
|
+
env_flag = os.getenv(AUTO_VECTOR_INDEX_ENV, "1").strip().lower() not in {
|
|
285
|
+
"0", "false", "no", "off",
|
|
286
|
+
}
|
|
287
|
+
self._auto_vector_index = bool(auto_vector_index) and env_flag
|
|
172
288
|
|
|
173
289
|
def available(self) -> bool:
|
|
174
290
|
return self._enable and self._kg is not None
|
|
@@ -228,9 +344,19 @@ class IngestionPipeline:
|
|
|
228
344
|
node_id = raw.get("node_id")
|
|
229
345
|
content_hash = raw.get("content_hash") or raw.get("sha256")
|
|
230
346
|
chunk_ids = list(raw.get("chunk_ids") or [])
|
|
231
|
-
embedded = bool(self._kg.node_is_embedded(node_id)) if node_id else False
|
|
232
347
|
title = raw.get("title") or item.title
|
|
233
348
|
|
|
349
|
+
# Incremental vector-index sync (opt-in via auto_vector_index +
|
|
350
|
+
# LATTICEAI_AUTO_VECTOR_INDEX). Exception-safe by contract: the graph
|
|
351
|
+
# write above already landed, so a vector failure only downgrades
|
|
352
|
+
# indexing_status to "pending" — index_status()/rebuild_vector_index()
|
|
353
|
+
# discover the same node as backlog and pick it up later.
|
|
354
|
+
indexing_status = "indexed"
|
|
355
|
+
vector_detail: Optional[str] = None
|
|
356
|
+
if node_id and self._auto_vector_index and not bool(raw.get("duplicate")):
|
|
357
|
+
indexing_status, vector_detail = self._sync_vector_index(node_id)
|
|
358
|
+
embedded = bool(self._kg.node_is_embedded(node_id)) if node_id else False
|
|
359
|
+
|
|
234
360
|
# Provenance capture must never turn an already-persisted ingest into a
|
|
235
361
|
# caller-visible failure: the graph write above succeeded, so a broken
|
|
236
362
|
# provenance table degrades the result instead of raising.
|
|
@@ -271,6 +397,7 @@ class IngestionPipeline:
|
|
|
271
397
|
except Exception: # noqa: BLE001 — audit must never break ingestion
|
|
272
398
|
pass
|
|
273
399
|
|
|
400
|
+
details = [d for d in (provenance_detail, vector_detail) if d]
|
|
274
401
|
return IngestionResult(
|
|
275
402
|
status="ok",
|
|
276
403
|
source_type=source_type,
|
|
@@ -282,11 +409,32 @@ class IngestionPipeline:
|
|
|
282
409
|
chunk_count=len(chunk_ids),
|
|
283
410
|
duplicate=bool(raw.get("duplicate")),
|
|
284
411
|
embedded=embedded,
|
|
285
|
-
indexing_status=
|
|
412
|
+
indexing_status=indexing_status,
|
|
286
413
|
provenance_id=prov.get("id"),
|
|
287
|
-
detail=
|
|
414
|
+
detail="; ".join(details) if details else None,
|
|
288
415
|
)
|
|
289
416
|
|
|
417
|
+
def _sync_vector_index(self, node_id: str) -> Tuple[str, Optional[str]]:
|
|
418
|
+
"""Best-effort incremental vector sync → (indexing_status, detail).
|
|
419
|
+
|
|
420
|
+
Any failure — missing method on older stores, embedding provider down,
|
|
421
|
+
storage error — yields ``("pending", detail)`` so a later
|
|
422
|
+
``rebuild_vector_index`` run picks the node up from the backlog.
|
|
423
|
+
"""
|
|
424
|
+
sync = getattr(self._kg, "index_node_incremental", None)
|
|
425
|
+
if not callable(sync):
|
|
426
|
+
# Older store without the incremental path: the write-side already
|
|
427
|
+
# embeds inline, so nothing extra to do.
|
|
428
|
+
return "indexed", None
|
|
429
|
+
try:
|
|
430
|
+
outcome = sync(node_id) or {}
|
|
431
|
+
except Exception as exc: # noqa: BLE001 — vector sync must never fail the ingest
|
|
432
|
+
return "pending", f"vector index sync failed: {exc}"
|
|
433
|
+
if str(outcome.get("status") or "") == "failed":
|
|
434
|
+
reason = outcome.get("detail") or "unknown error"
|
|
435
|
+
return "pending", f"vector index sync failed: {reason}"
|
|
436
|
+
return "indexed", None
|
|
437
|
+
|
|
290
438
|
# --- Large candidate #1: background / incremental scheduling (slice) ---
|
|
291
439
|
def schedule_background(
|
|
292
440
|
self,
|
|
@@ -307,6 +455,214 @@ class IngestionPipeline:
|
|
|
307
455
|
def get_background_job(self, job_id: str) -> Optional[BackgroundIngestionJob]:
|
|
308
456
|
return self._bg_queue.get(job_id)
|
|
309
457
|
|
|
458
|
+
def ingest_web_page(
|
|
459
|
+
self,
|
|
460
|
+
url: str,
|
|
461
|
+
extracted_text: str,
|
|
462
|
+
*,
|
|
463
|
+
title: Optional[str] = None,
|
|
464
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
465
|
+
owner: Optional[str] = None,
|
|
466
|
+
workspace_id: Optional[str] = None,
|
|
467
|
+
captured_at: Optional[str] = None,
|
|
468
|
+
user_email: Optional[str] = None,
|
|
469
|
+
) -> IngestionResult:
|
|
470
|
+
"""Ingest an *already-extracted* web page (see module docstring seam).
|
|
471
|
+
|
|
472
|
+
Fetching/parsing is upstream's responsibility (browser extension /
|
|
473
|
+
tools layer); this wrapper only normalizes ``(url, extracted_text)``
|
|
474
|
+
into an ``IngestionItem(source_type="web_url")`` and routes it through
|
|
475
|
+
the standard :meth:`ingest` door.
|
|
476
|
+
"""
|
|
477
|
+
url = str(url or "").strip()
|
|
478
|
+
if not url:
|
|
479
|
+
return IngestionResult(
|
|
480
|
+
status="failed", source_type="web_url",
|
|
481
|
+
indexing_status="skipped", detail="url required",
|
|
482
|
+
)
|
|
483
|
+
text = str(extracted_text or "")
|
|
484
|
+
if not text.strip():
|
|
485
|
+
return IngestionResult(
|
|
486
|
+
status="failed", source_type="web_url",
|
|
487
|
+
indexing_status="skipped",
|
|
488
|
+
detail=(
|
|
489
|
+
"extracted_text required — the graph layer does not fetch or "
|
|
490
|
+
"parse the web; extraction happens upstream."
|
|
491
|
+
),
|
|
492
|
+
)
|
|
493
|
+
item = IngestionItem(
|
|
494
|
+
source_type="web_url",
|
|
495
|
+
title=title or url,
|
|
496
|
+
text=text,
|
|
497
|
+
source_uri=url,
|
|
498
|
+
owner=owner,
|
|
499
|
+
workspace_id=workspace_id,
|
|
500
|
+
captured_at=captured_at,
|
|
501
|
+
metadata=dict(metadata or {}),
|
|
502
|
+
)
|
|
503
|
+
return self.ingest(item, user_email=user_email or owner)
|
|
504
|
+
|
|
505
|
+
def ingest_folder(
|
|
506
|
+
self,
|
|
507
|
+
root_path: Any,
|
|
508
|
+
*,
|
|
509
|
+
recursive: bool = True,
|
|
510
|
+
background: bool = False,
|
|
511
|
+
extensions: Optional[Iterable[str]] = None,
|
|
512
|
+
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
|
|
513
|
+
include_hidden: bool = False,
|
|
514
|
+
max_files: int = 1000,
|
|
515
|
+
max_errors: int = 25,
|
|
516
|
+
owner: Optional[str] = None,
|
|
517
|
+
workspace_id: Optional[str] = None,
|
|
518
|
+
user_email: Optional[str] = None,
|
|
519
|
+
) -> Dict[str, Any]:
|
|
520
|
+
"""Walk ``root_path`` and ingest every eligible file through the pipeline.
|
|
521
|
+
|
|
522
|
+
Filtering, in order: hard skip-list directories (``.git`` …), hidden
|
|
523
|
+
entries (unless ``include_hidden``), root ``.latticeignore`` patterns
|
|
524
|
+
(fnmatch globs; ``dir/`` suffix prunes directories), extension
|
|
525
|
+
allow-list, then ``max_file_bytes``. Text/code files are read inline so
|
|
526
|
+
their content is chunked; ``.pdf`` routes through the file door without
|
|
527
|
+
inline extraction.
|
|
528
|
+
|
|
529
|
+
``background=True`` schedules the built items on the existing
|
|
530
|
+
:class:`BackgroundIngestionQueue` instead of ingesting inline.
|
|
531
|
+
Returns a summary dict with counts and per-file errors (capped at
|
|
532
|
+
``max_errors``).
|
|
533
|
+
"""
|
|
534
|
+
summary: Dict[str, Any] = {
|
|
535
|
+
"root": str(root_path),
|
|
536
|
+
"recursive": bool(recursive),
|
|
537
|
+
"background": bool(background),
|
|
538
|
+
"scanned": 0,
|
|
539
|
+
"matched": 0,
|
|
540
|
+
"ingested": 0,
|
|
541
|
+
"duplicate": 0,
|
|
542
|
+
"failed": 0,
|
|
543
|
+
"skipped": {"ignored": 0, "extension": 0, "too_large": 0, "hidden": 0},
|
|
544
|
+
"truncated": False,
|
|
545
|
+
"errors": [],
|
|
546
|
+
}
|
|
547
|
+
try:
|
|
548
|
+
root = Path(root_path).expanduser()
|
|
549
|
+
except TypeError:
|
|
550
|
+
summary.update(status="failed", detail=f"invalid root path: {root_path!r}")
|
|
551
|
+
return summary
|
|
552
|
+
if not root.is_dir():
|
|
553
|
+
summary.update(status="failed", detail=f"not a directory: {root}")
|
|
554
|
+
return summary
|
|
555
|
+
if not self.available():
|
|
556
|
+
summary.update(
|
|
557
|
+
status="unavailable",
|
|
558
|
+
detail="Knowledge Graph is disabled (LATTICEAI_ENABLE_GRAPH).",
|
|
559
|
+
)
|
|
560
|
+
return summary
|
|
561
|
+
summary["root"] = str(root)
|
|
562
|
+
max_files = max(1, int(max_files))
|
|
563
|
+
max_errors = max(0, int(max_errors))
|
|
564
|
+
max_file_bytes = max(1, int(max_file_bytes))
|
|
565
|
+
allowed_exts = (
|
|
566
|
+
frozenset(str(e).lower() if str(e).startswith(".") else f".{str(e).lower()}" for e in extensions)
|
|
567
|
+
if extensions
|
|
568
|
+
else DEFAULT_FOLDER_EXTENSIONS
|
|
569
|
+
)
|
|
570
|
+
patterns = _load_latticeignore(root)
|
|
571
|
+
errors: List[Dict[str, Any]] = summary["errors"]
|
|
572
|
+
skipped = summary["skipped"]
|
|
573
|
+
items: List[IngestionItem] = []
|
|
574
|
+
|
|
575
|
+
def _record_error(path: Path, detail: str, status: str = "failed") -> None:
|
|
576
|
+
summary["failed"] += 1
|
|
577
|
+
if len(errors) < max_errors:
|
|
578
|
+
errors.append({"path": str(path), "status": status, "detail": detail})
|
|
579
|
+
|
|
580
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
581
|
+
current = Path(dirpath)
|
|
582
|
+
rel_dir = current.relative_to(root)
|
|
583
|
+
kept_dirs: List[str] = []
|
|
584
|
+
for name in sorted(dirnames):
|
|
585
|
+
if name in FOLDER_DEFAULT_SKIP_DIRS:
|
|
586
|
+
continue
|
|
587
|
+
if name.startswith(".") and not include_hidden:
|
|
588
|
+
continue
|
|
589
|
+
rel = name if str(rel_dir) == "." else (rel_dir / name).as_posix()
|
|
590
|
+
if _matches_ignore(rel, name, is_dir=True, patterns=patterns):
|
|
591
|
+
skipped["ignored"] += 1
|
|
592
|
+
continue
|
|
593
|
+
kept_dirs.append(name)
|
|
594
|
+
dirnames[:] = kept_dirs if recursive else []
|
|
595
|
+
|
|
596
|
+
for name in sorted(filenames):
|
|
597
|
+
if name == LATTICEIGNORE_FILENAME:
|
|
598
|
+
continue
|
|
599
|
+
summary["scanned"] += 1
|
|
600
|
+
path = current / name
|
|
601
|
+
rel = name if str(rel_dir) == "." else (rel_dir / name).as_posix()
|
|
602
|
+
if name.startswith(".") and not include_hidden:
|
|
603
|
+
skipped["hidden"] += 1
|
|
604
|
+
continue
|
|
605
|
+
if _matches_ignore(rel, name, is_dir=False, patterns=patterns):
|
|
606
|
+
skipped["ignored"] += 1
|
|
607
|
+
continue
|
|
608
|
+
ext = path.suffix.lower()
|
|
609
|
+
if ext not in allowed_exts:
|
|
610
|
+
skipped["extension"] += 1
|
|
611
|
+
continue
|
|
612
|
+
try:
|
|
613
|
+
size = path.stat().st_size
|
|
614
|
+
except OSError as exc:
|
|
615
|
+
_record_error(path, f"stat failed: {exc}")
|
|
616
|
+
continue
|
|
617
|
+
if size > max_file_bytes:
|
|
618
|
+
skipped["too_large"] += 1
|
|
619
|
+
continue
|
|
620
|
+
if len(items) >= max_files:
|
|
621
|
+
summary["truncated"] = True
|
|
622
|
+
break
|
|
623
|
+
item_metadata: Dict[str, Any] = {"relative_path": rel}
|
|
624
|
+
if ext in FOLDER_DOCUMENT_EXTENSIONS:
|
|
625
|
+
source_type = "pdf"
|
|
626
|
+
else:
|
|
627
|
+
source_type = "file"
|
|
628
|
+
try:
|
|
629
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
630
|
+
except OSError as exc:
|
|
631
|
+
_record_error(path, f"read failed: {exc}")
|
|
632
|
+
continue
|
|
633
|
+
item_metadata["extracted"] = {"content": content, "chars": len(content)}
|
|
634
|
+
items.append(
|
|
635
|
+
IngestionItem(
|
|
636
|
+
source_type=source_type,
|
|
637
|
+
title=name,
|
|
638
|
+
path=str(path),
|
|
639
|
+
source_uri=str(path),
|
|
640
|
+
owner=owner,
|
|
641
|
+
workspace_id=workspace_id,
|
|
642
|
+
metadata=item_metadata,
|
|
643
|
+
)
|
|
644
|
+
)
|
|
645
|
+
if summary["truncated"]:
|
|
646
|
+
break
|
|
647
|
+
|
|
648
|
+
summary["matched"] = len(items)
|
|
649
|
+
if background:
|
|
650
|
+
job = self.schedule_background(items, incremental=True)
|
|
651
|
+
summary.update(status="scheduled", job_id=job.job_id, scheduled=len(items))
|
|
652
|
+
return summary
|
|
653
|
+
|
|
654
|
+
for item in items:
|
|
655
|
+
result = self.ingest(item, user_email=user_email or owner)
|
|
656
|
+
if result.status == "ok":
|
|
657
|
+
if result.duplicate:
|
|
658
|
+
summary["duplicate"] += 1
|
|
659
|
+
else:
|
|
660
|
+
summary["ingested"] += 1
|
|
661
|
+
else:
|
|
662
|
+
_record_error(Path(item.path or ""), result.detail or result.status, result.status)
|
|
663
|
+
summary["status"] = "ok" if summary["failed"] == 0 else "partial"
|
|
664
|
+
return summary
|
|
665
|
+
|
|
310
666
|
# ── routing helpers ──────────────────────────────────────────────────────
|
|
311
667
|
def _ingest_text(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
|
|
312
668
|
text = item.text or ""
|
package/lattice_brain/quality.py
CHANGED
|
@@ -142,6 +142,46 @@ class RerankerInterface:
|
|
|
142
142
|
# -----------------------------
|
|
143
143
|
# 3. Memory Candidate Extraction / Scoring / Dedupe / Merge / Conflict / Retention
|
|
144
144
|
# -----------------------------
|
|
145
|
+
|
|
146
|
+
# Public content-signature helpers (v9.6.x graph-layer proactive seam).
|
|
147
|
+
# Extracted from MemoryQualityManager so the graph layer
|
|
148
|
+
# (lattice_brain.graph.proactive) and future ingestion gating can reuse the
|
|
149
|
+
# exact same dedupe semantics without instantiating the manager. Behaviour is
|
|
150
|
+
# byte-for-byte identical to the pre-existing private logic.
|
|
151
|
+
|
|
152
|
+
_SIGNATURE_STOPWORDS = {
|
|
153
|
+
"a", "an", "and", "for", "i", "is", "it", "mode", "the", "to",
|
|
154
|
+
"user", "users", "does", "do", "not", "like", "likes", "prefer",
|
|
155
|
+
"prefers", "want", "wants",
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def dedupe_key(content: str) -> str:
|
|
160
|
+
"""Stable near-exact signature for a piece of content.
|
|
161
|
+
|
|
162
|
+
sha256 prefix over the normalized text head plus a coarse length bucket —
|
|
163
|
+
the same key ``MemoryQualityManager.dedupe`` uses to collapse duplicates.
|
|
164
|
+
Two texts with the same key are treated as exact/near-exact duplicates.
|
|
165
|
+
"""
|
|
166
|
+
text = str(content or "")
|
|
167
|
+
norm = " ".join(text.lower().split())[:200]
|
|
168
|
+
return hashlib.sha256((norm + f"|{len(text)//50}").encode()).hexdigest()[:16]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def content_signature(content: str) -> set:
|
|
172
|
+
"""Token-set signature (stopword-filtered) for overlap/jaccard comparison.
|
|
173
|
+
|
|
174
|
+
Mirrors ``MemoryQualityManager._content_signature`` — kept public so
|
|
175
|
+
graph-layer duplicate/contradiction detection shares one definition.
|
|
176
|
+
"""
|
|
177
|
+
tokens = set(re.findall(r"\w+", str(content or "").lower()))
|
|
178
|
+
return {
|
|
179
|
+
token
|
|
180
|
+
for token in tokens
|
|
181
|
+
if len(token) > 2 and token not in _SIGNATURE_STOPWORDS
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
145
185
|
@dataclass
|
|
146
186
|
class MemoryCandidate:
|
|
147
187
|
id: str
|
|
@@ -193,8 +233,7 @@ class MemoryQualityManager:
|
|
|
193
233
|
kept = []
|
|
194
234
|
seen = set()
|
|
195
235
|
for c in cands:
|
|
196
|
-
|
|
197
|
-
h = hashlib.sha256((norm + f"|{len(c.content)//50}").encode()).hexdigest()[:16]
|
|
236
|
+
h = dedupe_key(c.content)
|
|
198
237
|
if h not in seen:
|
|
199
238
|
seen.add(h)
|
|
200
239
|
kept.append(c)
|
|
@@ -247,13 +286,7 @@ class MemoryQualityManager:
|
|
|
247
286
|
return any(pattern in lowered for pattern in self._POSITIVE_PATTERNS)
|
|
248
287
|
|
|
249
288
|
def _content_signature(self, content: str) -> set[str]:
|
|
250
|
-
|
|
251
|
-
"a", "an", "and", "for", "i", "is", "it", "mode", "the", "to",
|
|
252
|
-
"user", "users", "does", "do", "not", "like", "likes", "prefer",
|
|
253
|
-
"prefers", "want", "wants",
|
|
254
|
-
}
|
|
255
|
-
tokens = set(re.findall(r"\w+", content.lower()))
|
|
256
|
-
return {token for token in tokens if len(token) > 2 and token not in stopwords}
|
|
289
|
+
return content_signature(content)
|
|
257
290
|
|
|
258
291
|
# --- Large candidate #4 slice: proactive / temporal contradiction detection ---
|
|
259
292
|
def detect_temporal_contradictions(self, memories: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
@@ -516,6 +549,8 @@ class LatticeBrainQuality:
|
|
|
516
549
|
__all__ = [
|
|
517
550
|
"BM25Scorer",
|
|
518
551
|
"ContextGuardrails",
|
|
552
|
+
"content_signature",
|
|
553
|
+
"dedupe_key",
|
|
519
554
|
"EmbeddingFallbackLabeller",
|
|
520
555
|
"EmbeddingLabel",
|
|
521
556
|
"GraphEdgeQuality",
|
|
@@ -10,6 +10,33 @@ the operational objects first-class: handoffs, context packets, review/retry
|
|
|
10
10
|
history, replayable timeline events, and explicit planning records. The default
|
|
11
11
|
runner is still deterministic and LLM-free so tests, local demos, and Community
|
|
12
12
|
installations can exercise the full Planner -> Executor -> Reviewer loop.
|
|
13
|
+
|
|
14
|
+
Consistency with the single-agent harness (latticeai.core.agent)
|
|
15
|
+
----------------------------------------------------------------
|
|
16
|
+
Both runtimes expose the shared ``agent-run-contract/v1`` envelope
|
|
17
|
+
(:mod:`.contracts`), and every terminal status this orchestrator emits
|
|
18
|
+
(``ok`` / ``retried_ok`` / ``failed``) is a member of
|
|
19
|
+
``statuses.RUN_TERMINAL_STATUSES``. Three differences are intentional design,
|
|
20
|
+
not drift:
|
|
21
|
+
|
|
22
|
+
* **Tool dispatch** — this orchestrator is pure and never executes tools
|
|
23
|
+
itself. Tool work reachable from a multi-agent step flows through the
|
|
24
|
+
*injected* ``workflow_runner`` / ``plugin_runner`` seams (wired in
|
|
25
|
+
``latticeai.services.platform_runtime``), and those seams route every call
|
|
26
|
+
through the same shared ``hooks.dispatch_tool`` pre_tool/post_tool lifecycle
|
|
27
|
+
the single-agent loop uses.
|
|
28
|
+
* **Change governance** — the single-agent loop stages mutations of existing
|
|
29
|
+
content as review proposals (``AgentDeps.change_governor``); the injected
|
|
30
|
+
workflow tool node instead pauses non-auto-approve tools into
|
|
31
|
+
``awaiting_approval`` (``ApprovalRequired``). Different mechanisms, same
|
|
32
|
+
fail-closed outcome: no unapproved mutation executes from either runtime.
|
|
33
|
+
* **Tracing** — the single-agent loop records a ``LoopTrace`` event stream;
|
|
34
|
+
this runtime records replayable ``timeline`` events. Both surface uniformly
|
|
35
|
+
as the contract's ``timeline``.
|
|
36
|
+
|
|
37
|
+
Run-level ``pre_run`` / ``post_run`` hooks fire in
|
|
38
|
+
``lattice_brain.runtime.agent_runtime.AgentRuntime``, which wraps this
|
|
39
|
+
orchestrator for the product ``/agents`` surface.
|
|
13
40
|
"""
|
|
14
41
|
|
|
15
42
|
from __future__ import annotations
|
|
@@ -21,7 +48,7 @@ from .contracts import multi_agent_contract
|
|
|
21
48
|
from ..utils import now_iso as _now
|
|
22
49
|
|
|
23
50
|
|
|
24
|
-
MULTI_AGENT_VERSION = "9.
|
|
51
|
+
MULTI_AGENT_VERSION = "9.7.0"
|
|
25
52
|
|
|
26
53
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
27
54
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
|
@@ -742,7 +769,16 @@ class MultiAgentOrchestrator:
|
|
|
742
769
|
# let downstream roles review stale or missing output and could
|
|
743
770
|
# incorrectly convert a failed run into an approved one.
|
|
744
771
|
if str(role_result.get("status") or "").lower() == "error":
|
|
745
|
-
|
|
772
|
+
# Both role-error result shapes are honored: a raised exception
|
|
773
|
+
# (``_run_role``) carries ``error``; an llm_role_runner failure
|
|
774
|
+
# carries ``reason`` (with the raw model output preserved in
|
|
775
|
+
# ``ctx.review``). Either way the terminal timeline event names
|
|
776
|
+
# the real cause instead of a generic placeholder.
|
|
777
|
+
reason = str(
|
|
778
|
+
role_result.get("error")
|
|
779
|
+
or role_result.get("reason")
|
|
780
|
+
or f"{role} role failed"
|
|
781
|
+
)
|
|
746
782
|
existing_review = dict(ctx.review or {})
|
|
747
783
|
ctx.review = existing_review or {
|
|
748
784
|
"outcome": "reject",
|
package/latticeai/__init__.py
CHANGED
|
@@ -18,6 +18,15 @@ from latticeai.services.brain_intelligence import BrainIntelligenceService
|
|
|
18
18
|
|
|
19
19
|
class ConsolidateRequest(BaseModel):
|
|
20
20
|
apply: bool = False
|
|
21
|
+
# v9.6.x additive alias: dry_run=true means apply=false. When provided it
|
|
22
|
+
# takes precedence over ``apply`` (explicit intent wins); omitted keeps the
|
|
23
|
+
# v9.3.0 contract unchanged. Default behaviour is always a dry run.
|
|
24
|
+
dry_run: Optional[bool] = None
|
|
25
|
+
|
|
26
|
+
def effective_apply(self) -> bool:
|
|
27
|
+
if self.dry_run is not None:
|
|
28
|
+
return not self.dry_run
|
|
29
|
+
return self.apply
|
|
21
30
|
|
|
22
31
|
|
|
23
32
|
def create_brain_intelligence_router(
|
|
@@ -48,11 +57,27 @@ def create_brain_intelligence_router(
|
|
|
48
57
|
scope = gate_read(request)
|
|
49
58
|
return service.contradictions(user_email=user, workspace_id=scope)
|
|
50
59
|
|
|
60
|
+
@router.get("/api/brain/duplicates")
|
|
61
|
+
async def brain_duplicates(request: Request):
|
|
62
|
+
"""Graph-layer duplicate node candidates (read-only)."""
|
|
63
|
+
user = require_user(request)
|
|
64
|
+
scope = gate_read(request)
|
|
65
|
+
return service.graph_duplicates(user_email=user, workspace_id=scope)
|
|
66
|
+
|
|
67
|
+
@router.get("/api/brain/quality-report")
|
|
68
|
+
async def brain_quality_report(request: Request):
|
|
69
|
+
"""Combined graph quality report: duplicates, contradictions, stale
|
|
70
|
+
nodes, edge quality (read-only)."""
|
|
71
|
+
user = require_user(request)
|
|
72
|
+
scope = gate_read(request)
|
|
73
|
+
return service.quality_report(user_email=user, workspace_id=scope)
|
|
74
|
+
|
|
51
75
|
@router.post("/api/brain/consolidate")
|
|
52
76
|
async def brain_consolidate(req: ConsolidateRequest, request: Request):
|
|
53
77
|
user = require_user(request)
|
|
54
|
-
|
|
55
|
-
|
|
78
|
+
apply = req.effective_apply()
|
|
79
|
+
scope = gate_write(request) if apply else gate_read(request)
|
|
80
|
+
result = service.consolidate(apply=apply, user_email=user, workspace_id=scope)
|
|
56
81
|
append_audit_event(
|
|
57
82
|
"brain_consolidate",
|
|
58
83
|
user_email=user,
|