ltcai 8.7.0 → 8.9.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 +38 -26
- package/auto_setup.py +73 -8
- package/docs/CHANGELOG.md +63 -0
- package/docs/CODE_REVIEW_2026-07-06.md +764 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +4 -3
- package/docs/DEVELOPMENT.md +10 -10
- package/docs/LEGACY_COMPATIBILITY.md +33 -14
- package/docs/ONBOARDING.md +2 -2
- package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
- package/docs/TRUST_MODEL.md +5 -1
- package/docs/WHY_LATTICE.md +4 -3
- package/docs/architecture.md +4 -0
- package/docs/kg-schema.md +1 -1
- package/kg_schema.py +1 -1
- package/knowledge_graph.py +2 -2
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/conversations.py +156 -21
- package/lattice_brain/core.py +1 -1
- package/lattice_brain/graph/_kg_common.py +12 -34
- package/lattice_brain/graph/json_utils.py +25 -0
- package/lattice_brain/graph/retrieval.py +66 -27
- package/lattice_brain/graph/runtime.py +16 -0
- package/lattice_brain/ingestion.py +35 -20
- package/lattice_brain/runtime/agent_runtime.py +37 -3
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +31 -14
- package/latticeai/api/mcp.py +3 -2
- package/latticeai/api/models.py +4 -1
- package/latticeai/api/permissions.py +69 -30
- package/latticeai/api/setup.py +17 -2
- package/latticeai/api/tools.py +104 -62
- package/latticeai/app_factory.py +93 -10
- package/latticeai/core/agent.py +25 -7
- package/latticeai/core/legacy_compatibility.py +36 -25
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/sessions.py +11 -3
- package/latticeai/core/tool_registry.py +15 -4
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/bootstrap.py +1 -1
- package/latticeai/runtime/persistence_runtime.py +1 -1
- package/latticeai/runtime/platform_services_runtime.py +1 -1
- package/latticeai/services/app_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/memory_service.py +38 -7
- package/latticeai/services/model_engines.py +79 -12
- package/latticeai/services/model_runtime.py +24 -4
- package/latticeai/services/process_audit.py +208 -0
- package/latticeai/services/product_readiness.py +11 -11
- package/latticeai/services/search_service.py +106 -30
- package/latticeai/services/tool_dispatch.py +66 -0
- package/latticeai/services/workspace_service.py +15 -0
- package/package.json +1 -1
- package/scripts/check_i18n_literals.mjs +20 -8
- package/scripts/i18n_literal_allowlist.json +34 -0
- package/scripts/lint_frontend.mjs +6 -2
- package/setup_wizard.py +185 -19
- 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-_U7mhXir.js → Act-fZokUnC0.js} +1 -1
- package/static/app/assets/{Brain-BxyTHZ21.js → Brain-DtyuWubr.js} +2 -2
- package/static/app/assets/{Capture-DyDKWNh9.js → Capture-D5KV3Cu7.js} +1 -1
- package/static/app/assets/{Library-DJ8KioFM.js → Library-C9kyFkSt.js} +1 -1
- package/static/app/assets/{System-C0FIb3OO.js → System-VbChmX7r.js} +1 -1
- package/static/app/assets/index-DCh5AoXt.css +2 -0
- package/static/app/assets/index-DPdcPoF0.js +17 -0
- package/static/app/assets/{primitives-BywkNS3f.js → primitives-DFeanEV6.js} +1 -1
- package/static/app/assets/{textarea-CQ61Rycp.js → textarea-CD8UNKIy.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/lattice_brain/_kg_common.py +0 -11
- package/lattice_brain/discovery.py +0 -11
- package/lattice_brain/documents.py +0 -11
- package/lattice_brain/identity.py +0 -11
- package/lattice_brain/ingest.py +0 -11
- package/lattice_brain/network.py +0 -11
- package/lattice_brain/projection.py +0 -11
- package/lattice_brain/provenance.py +0 -11
- package/lattice_brain/retrieval.py +0 -11
- package/lattice_brain/schema.py +0 -11
- package/lattice_brain/store.py +0 -11
- package/lattice_brain/write_master.py +0 -11
- package/latticeai/brain/__init__.py +0 -36
- package/latticeai/brain/_kg_common.py +0 -17
- package/latticeai/brain/context.py +0 -17
- package/latticeai/brain/conversations.py +0 -17
- package/latticeai/brain/discovery.py +0 -17
- package/latticeai/brain/documents.py +0 -17
- package/latticeai/brain/identity.py +0 -17
- package/latticeai/brain/ingest.py +0 -17
- package/latticeai/brain/memory.py +0 -17
- package/latticeai/brain/network.py +0 -17
- package/latticeai/brain/projection.py +0 -17
- package/latticeai/brain/provenance.py +0 -17
- package/latticeai/brain/retrieval.py +0 -17
- package/latticeai/brain/schema.py +0 -17
- package/latticeai/brain/store.py +0 -17
- package/latticeai/brain/write_master.py +0 -17
- package/latticeai/services/agent_runtime.py +0 -11
- package/static/app/assets/index-Bh7IIlyY.js +0 -16
- package/static/app/assets/index-_M5aCv21.css +0 -2
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""JSON helpers shared by graph storage, projection, and retrieval modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _json(data: Optional[Dict[str, Any]]) -> str:
|
|
11
|
+
return json.dumps(data or {}, ensure_ascii=False, sort_keys=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _safe_loads(raw: Optional[str]) -> Dict[str, Any]:
|
|
15
|
+
"""Tolerantly parse a metadata_json column — returns {} on corrupt rows."""
|
|
16
|
+
if not raw:
|
|
17
|
+
return {}
|
|
18
|
+
try:
|
|
19
|
+
value = json.loads(raw)
|
|
20
|
+
return value if isinstance(value, dict) else {}
|
|
21
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
22
|
+
logging.warning(
|
|
23
|
+
"knowledge_graph: corrupt metadata_json (%s) — using empty dict", e
|
|
24
|
+
)
|
|
25
|
+
return {}
|
|
@@ -55,9 +55,18 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
55
55
|
extracted = meta.get("extracted") or {}
|
|
56
56
|
node_id = row["id"]
|
|
57
57
|
chunk_count = conn.execute(
|
|
58
|
-
|
|
59
|
-
(
|
|
58
|
+
"SELECT COUNT(*) AS c FROM chunks WHERE source_node=?",
|
|
59
|
+
(node_id,),
|
|
60
60
|
).fetchone()["c"]
|
|
61
|
+
if not chunk_count:
|
|
62
|
+
# Legacy projections represented chunks as graph nodes and
|
|
63
|
+
# linked them only through metadata_json. Keep read
|
|
64
|
+
# compatibility without making the fragile LIKE path the
|
|
65
|
+
# primary query.
|
|
66
|
+
chunk_count = conn.execute(
|
|
67
|
+
f"SELECT COUNT(*) AS c FROM {nt} WHERE type='Chunk' AND metadata_json LIKE ?",
|
|
68
|
+
(f"%{node_id}%",),
|
|
69
|
+
).fetchone()["c"]
|
|
61
70
|
documents.append(
|
|
62
71
|
{
|
|
63
72
|
"id": node_id,
|
|
@@ -255,7 +264,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
255
264
|
node.pop("_raw_importance", None)
|
|
256
265
|
return {"nodes": nodes, "edges": edges}
|
|
257
266
|
|
|
258
|
-
def search(self, query: str, limit: int = 30) -> Dict[str, Any]:
|
|
267
|
+
def search(self, query: str, limit: int = 30, *, allowed_workspaces=None) -> Dict[str, Any]:
|
|
259
268
|
query = str(query or "").strip()
|
|
260
269
|
q = f"%{query}%"
|
|
261
270
|
limit = max(1, min(int(limit or 30), 100))
|
|
@@ -347,9 +356,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
347
356
|
# the legacy LIKE path regardless of FTS bm25 tie ordering.
|
|
348
357
|
rows = sorted(rows, key=lambda r: r["id"])
|
|
349
358
|
rows = sorted(rows, key=score, reverse=True)[:limit]
|
|
350
|
-
|
|
351
|
-
"query": query,
|
|
352
|
-
"matches": [
|
|
359
|
+
matches = [
|
|
353
360
|
{
|
|
354
361
|
"id": row["id"],
|
|
355
362
|
"type": row["type"],
|
|
@@ -359,15 +366,17 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
359
366
|
"updated_at": row["updated_at"],
|
|
360
367
|
}
|
|
361
368
|
for row in rows
|
|
362
|
-
]
|
|
363
|
-
|
|
369
|
+
]
|
|
370
|
+
if allowed_workspaces is not None:
|
|
371
|
+
matches = self.filter_scoped_nodes(matches, allowed_workspaces)
|
|
372
|
+
return {"query": query, "matches": matches}
|
|
364
373
|
|
|
365
|
-
def context_for_query(self, query: str, limit: int = 6) -> str:
|
|
374
|
+
def context_for_query(self, query: str, limit: int = 6, *, allowed_workspaces=None) -> str:
|
|
366
375
|
"""Return compact graph-backed RAG context for chat generation."""
|
|
367
376
|
query = str(query or "").strip()
|
|
368
377
|
if not query:
|
|
369
378
|
return ""
|
|
370
|
-
matches = self.search(query, limit).get("matches", [])
|
|
379
|
+
matches = self.search(query, limit, allowed_workspaces=allowed_workspaces).get("matches", [])
|
|
371
380
|
if not matches:
|
|
372
381
|
topics = _topic_candidates(query, limit=4)
|
|
373
382
|
if topics:
|
|
@@ -404,6 +413,8 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
404
413
|
)
|
|
405
414
|
if len(matches) >= limit:
|
|
406
415
|
break
|
|
416
|
+
if allowed_workspaces is not None:
|
|
417
|
+
matches = self.filter_scoped_nodes(matches, allowed_workspaces)
|
|
407
418
|
lines = []
|
|
408
419
|
for match in matches[:limit]:
|
|
409
420
|
meta = match.get("metadata") or {}
|
|
@@ -420,8 +431,10 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
420
431
|
)
|
|
421
432
|
return "\n".join(lines)
|
|
422
433
|
|
|
423
|
-
def neighbors(self, node_id: str) -> Dict[str, Any]:
|
|
434
|
+
def neighbors(self, node_id: str, *, allowed_workspaces=None) -> Dict[str, Any]:
|
|
424
435
|
"""Return direct neighbors (1-hop) of a node."""
|
|
436
|
+
if allowed_workspaces is not None and not self.filter_scoped_nodes([{"id": node_id}], allowed_workspaces):
|
|
437
|
+
raise ValueError(f"graph node not found: {node_id}")
|
|
425
438
|
nt, et = self._read_tables()
|
|
426
439
|
with self._connect() as conn:
|
|
427
440
|
edge_rows = conn.execute(
|
|
@@ -458,9 +471,17 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
458
471
|
list(neighbor_ids),
|
|
459
472
|
)
|
|
460
473
|
]
|
|
474
|
+
if allowed_workspaces is not None:
|
|
475
|
+
nodes = self.filter_scoped_nodes(nodes, allowed_workspaces)
|
|
476
|
+
kept = {node.get("id") for node in nodes}
|
|
477
|
+
edges = [
|
|
478
|
+
edge for edge in edges
|
|
479
|
+
if (edge.get("from") == node_id or edge.get("from") in kept)
|
|
480
|
+
and (edge.get("to") == node_id or edge.get("to") in kept)
|
|
481
|
+
]
|
|
461
482
|
return {"node_id": node_id, "neighbors": nodes, "edges": edges}
|
|
462
483
|
|
|
463
|
-
def get_node(self, node_id: str) -> Dict[str, Any]:
|
|
484
|
+
def get_node(self, node_id: str, *, allowed_workspaces=None) -> Dict[str, Any]:
|
|
464
485
|
node_id = str(node_id or "").strip()
|
|
465
486
|
if not node_id:
|
|
466
487
|
raise ValueError("node_id required")
|
|
@@ -480,7 +501,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
480
501
|
f"SELECT COUNT(*) AS c FROM {et} WHERE from_node=? OR to_node=?",
|
|
481
502
|
(node_id, node_id),
|
|
482
503
|
).fetchone()["c"]
|
|
483
|
-
|
|
504
|
+
node = {
|
|
484
505
|
"id": row["id"],
|
|
485
506
|
"type": row["type"],
|
|
486
507
|
"title": row["title"],
|
|
@@ -489,6 +510,9 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
489
510
|
"updated_at": row["updated_at"],
|
|
490
511
|
"degree": degree,
|
|
491
512
|
}
|
|
513
|
+
if allowed_workspaces is not None and not self.filter_scoped_nodes([node], allowed_workspaces):
|
|
514
|
+
raise ValueError(f"graph node not found: {node_id}")
|
|
515
|
+
return node
|
|
492
516
|
|
|
493
517
|
def relationship_search(
|
|
494
518
|
self,
|
|
@@ -497,6 +521,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
497
521
|
node_id: str = "",
|
|
498
522
|
relationship_type: str = "",
|
|
499
523
|
limit: int = 30,
|
|
524
|
+
allowed_workspaces=None,
|
|
500
525
|
) -> Dict[str, Any]:
|
|
501
526
|
query = str(query or "").strip()
|
|
502
527
|
node_id = str(node_id or "").strip()
|
|
@@ -535,11 +560,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
535
560
|
""",
|
|
536
561
|
(*params, limit),
|
|
537
562
|
).fetchall()
|
|
538
|
-
|
|
539
|
-
"query": query,
|
|
540
|
-
"node_id": node_id,
|
|
541
|
-
"relationship_type": relationship_type,
|
|
542
|
-
"relationships": [
|
|
563
|
+
relationships = [
|
|
543
564
|
{
|
|
544
565
|
"id": row["id"],
|
|
545
566
|
"type": row["type"],
|
|
@@ -562,15 +583,32 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
562
583
|
},
|
|
563
584
|
}
|
|
564
585
|
for row in rows
|
|
565
|
-
]
|
|
586
|
+
]
|
|
587
|
+
if allowed_workspaces is not None:
|
|
588
|
+
kept = []
|
|
589
|
+
for rel in relationships:
|
|
590
|
+
endpoints = [
|
|
591
|
+
{"id": (rel.get("source") or {}).get("id")},
|
|
592
|
+
{"id": (rel.get("target") or {}).get("id")},
|
|
593
|
+
]
|
|
594
|
+
if len(self.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
|
|
595
|
+
kept.append(rel)
|
|
596
|
+
relationships = kept
|
|
597
|
+
return {
|
|
598
|
+
"query": query,
|
|
599
|
+
"node_id": node_id,
|
|
600
|
+
"relationship_type": relationship_type,
|
|
601
|
+
"relationships": relationships,
|
|
566
602
|
}
|
|
567
603
|
|
|
568
604
|
def traverse(
|
|
569
|
-
self, node_id: str, *, depth: int = 1, limit: int = 100
|
|
605
|
+
self, node_id: str, *, depth: int = 1, limit: int = 100, allowed_workspaces=None
|
|
570
606
|
) -> Dict[str, Any]:
|
|
571
607
|
node_id = str(node_id or "").strip()
|
|
572
608
|
if not node_id:
|
|
573
609
|
raise ValueError("node_id required")
|
|
610
|
+
if allowed_workspaces is not None and not self.filter_scoped_nodes([{"id": node_id}], allowed_workspaces):
|
|
611
|
+
raise ValueError(f"graph node not found: {node_id}")
|
|
574
612
|
depth = max(0, min(int(depth or 1), 4))
|
|
575
613
|
limit = max(1, min(int(limit or 100), 500))
|
|
576
614
|
nt, et = self._read_tables()
|
|
@@ -617,10 +655,7 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
617
655
|
""",
|
|
618
656
|
list(visited),
|
|
619
657
|
).fetchall()
|
|
620
|
-
|
|
621
|
-
"root": node_id,
|
|
622
|
-
"depth": depth,
|
|
623
|
-
"nodes": [
|
|
658
|
+
nodes = [
|
|
624
659
|
{
|
|
625
660
|
"id": row["id"],
|
|
626
661
|
"type": row["type"],
|
|
@@ -630,9 +665,13 @@ class KnowledgeGraphRetrievalMixin:
|
|
|
630
665
|
"updated_at": row["updated_at"],
|
|
631
666
|
}
|
|
632
667
|
for row in node_rows
|
|
633
|
-
]
|
|
634
|
-
|
|
635
|
-
|
|
668
|
+
]
|
|
669
|
+
edges = list(edges_by_id.values())
|
|
670
|
+
if allowed_workspaces is not None:
|
|
671
|
+
nodes = self.filter_scoped_nodes(nodes, allowed_workspaces)
|
|
672
|
+
kept = {node.get("id") for node in nodes}
|
|
673
|
+
edges = [edge for edge in edges if edge.get("from") in kept and edge.get("to") in kept]
|
|
674
|
+
return {"root": node_id, "depth": depth, "nodes": nodes, "edges": edges}
|
|
636
675
|
|
|
637
676
|
def _iter_vector_source_items(
|
|
638
677
|
self,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Runtime hooks for the knowledge graph package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
_llm_router_ref: Any = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def set_llm_router(router_instance: Any) -> None:
|
|
11
|
+
global _llm_router_ref
|
|
12
|
+
_llm_router_ref = router_instance
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_llm_router() -> Any:
|
|
16
|
+
return _llm_router_ref
|
|
@@ -132,7 +132,7 @@ class IngestionPipeline:
|
|
|
132
132
|
# ── public API ───────────────────────────────────────────────────────────
|
|
133
133
|
def ingest(self, item: IngestionItem, *, user_email: Optional[str] = None) -> IngestionResult:
|
|
134
134
|
"""Normalize, hash, route through dispatch_tool, and record provenance."""
|
|
135
|
-
source_type = str(item.source_type or "text").strip()
|
|
135
|
+
source_type = str(item.source_type or "text").strip().lower()
|
|
136
136
|
if not self.available():
|
|
137
137
|
return IngestionResult(
|
|
138
138
|
status="unavailable", source_type=source_type,
|
|
@@ -187,25 +187,33 @@ class IngestionPipeline:
|
|
|
187
187
|
embedded = bool(self._kg.node_is_embedded(node_id)) if node_id else False
|
|
188
188
|
title = raw.get("title") or item.title
|
|
189
189
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
190
|
+
# Provenance capture must never turn an already-persisted ingest into a
|
|
191
|
+
# caller-visible failure: the graph write above succeeded, so a broken
|
|
192
|
+
# provenance table degrades the result instead of raising.
|
|
193
|
+
provenance_detail: Optional[str] = None
|
|
194
|
+
try:
|
|
195
|
+
prov = self._kg.record_provenance(
|
|
196
|
+
node_id=node_id,
|
|
197
|
+
source_type=source_type,
|
|
198
|
+
pipeline=self._pipeline_name,
|
|
199
|
+
source_uri=item.source_uri,
|
|
200
|
+
content_hash=content_hash,
|
|
201
|
+
title=title,
|
|
202
|
+
owner=owner,
|
|
203
|
+
workspace_id=item.workspace_id,
|
|
204
|
+
captured_at=captured_at,
|
|
205
|
+
modified_at=item.modified_at,
|
|
206
|
+
embedded=embedded,
|
|
207
|
+
linked=bool(raw.get("source_node_id")),
|
|
208
|
+
duplicate=bool(raw.get("duplicate")),
|
|
209
|
+
agent_used=item.agent_used,
|
|
210
|
+
chunk_count=len(chunk_ids),
|
|
211
|
+
permissions=item.permissions,
|
|
212
|
+
metadata=item.metadata,
|
|
213
|
+
)
|
|
214
|
+
except Exception as exc: # noqa: BLE001 — the ingest itself already landed
|
|
215
|
+
prov = {}
|
|
216
|
+
provenance_detail = f"provenance capture failed: {exc}"
|
|
209
217
|
if self._audit is not None:
|
|
210
218
|
try:
|
|
211
219
|
self._audit(
|
|
@@ -232,11 +240,16 @@ class IngestionPipeline:
|
|
|
232
240
|
embedded=embedded,
|
|
233
241
|
indexing_status="indexed",
|
|
234
242
|
provenance_id=prov.get("id"),
|
|
243
|
+
detail=provenance_detail,
|
|
235
244
|
)
|
|
236
245
|
|
|
237
246
|
# ── routing helpers ──────────────────────────────────────────────────────
|
|
238
247
|
def _ingest_text(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
|
|
239
248
|
text = item.text or ""
|
|
249
|
+
if not text.strip():
|
|
250
|
+
raise ValueError(
|
|
251
|
+
f"Empty content: {source_type} ingestion requires non-empty text."
|
|
252
|
+
)
|
|
240
253
|
if len(text.encode("utf-8", "ignore")) > self._max_text_bytes:
|
|
241
254
|
raise ValueError(
|
|
242
255
|
f"Text payload exceeds the {self._max_text_bytes // (1024 * 1024)}MB ingestion limit."
|
|
@@ -297,6 +310,8 @@ class IngestionPipeline:
|
|
|
297
310
|
path = Path(item.path)
|
|
298
311
|
if not path.exists():
|
|
299
312
|
raise FileNotFoundError(f"File not found: {path}")
|
|
313
|
+
if path.is_dir():
|
|
314
|
+
raise ValueError(f"File ingestion requires a file, got a directory: {path}")
|
|
300
315
|
return self._kg.ingest_document(
|
|
301
316
|
path,
|
|
302
317
|
original_filename=item.title or path.name,
|
|
@@ -42,6 +42,7 @@ from .contracts import (
|
|
|
42
42
|
contract_views,
|
|
43
43
|
extract_contract,
|
|
44
44
|
multi_agent_contract,
|
|
45
|
+
run_record_contract,
|
|
45
46
|
runtime_boundary_contract,
|
|
46
47
|
)
|
|
47
48
|
|
|
@@ -308,7 +309,7 @@ class AgentRuntime:
|
|
|
308
309
|
def get_run(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
309
310
|
run = self._store.get_agent_run(run_id, workspace_id=scope)
|
|
310
311
|
payload = {"run": run}
|
|
311
|
-
contract =
|
|
312
|
+
contract = self._ensure_contract(run)
|
|
312
313
|
if contract is not None:
|
|
313
314
|
payload["contract"] = contract_view(contract)
|
|
314
315
|
return payload
|
|
@@ -324,7 +325,7 @@ class AgentRuntime:
|
|
|
324
325
|
def events(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
325
326
|
run = self._store.get_agent_run(run_id, workspace_id=scope)
|
|
326
327
|
status = str(run.get("status") or "")
|
|
327
|
-
contract =
|
|
328
|
+
contract = self._ensure_contract(run)
|
|
328
329
|
return {
|
|
329
330
|
"run_id": run_id,
|
|
330
331
|
"status": status,
|
|
@@ -364,6 +365,35 @@ class AgentRuntime:
|
|
|
364
365
|
def _clamp_retries(self, max_retries: int) -> int:
|
|
365
366
|
return max(0, min(int(max_retries or 0), self._max_retries_cap))
|
|
366
367
|
|
|
368
|
+
def _validate_roles(self, roles: Optional[List[str]]) -> Optional[List[str]]:
|
|
369
|
+
"""Reject unknown roles at the boundary instead of deep in orchestration.
|
|
370
|
+
|
|
371
|
+
``preview`` reports unknown roles as a blocking reason; execution paths
|
|
372
|
+
must enforce the same contract so a run can never be recorded with a
|
|
373
|
+
role the runtime does not own.
|
|
374
|
+
"""
|
|
375
|
+
if not roles:
|
|
376
|
+
return None
|
|
377
|
+
unknown = [role for role in roles if role not in AGENT_ROLES]
|
|
378
|
+
if unknown:
|
|
379
|
+
raise ValueError(f"unknown roles: {', '.join(unknown)}")
|
|
380
|
+
return list(roles)
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def _ensure_contract(record: Any) -> Optional[Dict[str, Any]]:
|
|
384
|
+
"""Return the record's family contract, synthesizing one for legacy rows.
|
|
385
|
+
|
|
386
|
+
Every read surface (get_run/events/replay) must expose the
|
|
387
|
+
``agent-run-contract/v1`` envelope even for runs persisted before the
|
|
388
|
+
contract family existed, so consumers never need a legacy branch.
|
|
389
|
+
"""
|
|
390
|
+
contract = extract_contract(record)
|
|
391
|
+
if contract is not None:
|
|
392
|
+
return contract
|
|
393
|
+
if isinstance(record, dict) and record.get("id"):
|
|
394
|
+
return run_record_contract(record)
|
|
395
|
+
return None
|
|
396
|
+
|
|
367
397
|
@staticmethod
|
|
368
398
|
def _result_patch(result: Any, goal: str) -> Dict[str, Any]:
|
|
369
399
|
return {
|
|
@@ -420,6 +450,7 @@ class AgentRuntime:
|
|
|
420
450
|
"""Create the durable queued row used by the async executor."""
|
|
421
451
|
if not str(goal or "").strip():
|
|
422
452
|
raise ValueError("goal is required")
|
|
453
|
+
roles = self._validate_roles(roles)
|
|
423
454
|
pre_dispatch = self._fire_pre_run(
|
|
424
455
|
goal=goal,
|
|
425
456
|
roles=roles,
|
|
@@ -454,7 +485,9 @@ class AgentRuntime:
|
|
|
454
485
|
execution_mode="async",
|
|
455
486
|
requested_roles=roles or None,
|
|
456
487
|
inputs=inputs or {},
|
|
457
|
-
|
|
488
|
+
# Persist the clamped budget so the durable row reflects what the
|
|
489
|
+
# executor will actually honor, not the raw client request.
|
|
490
|
+
max_retries=self._clamp_retries(max_retries),
|
|
458
491
|
)
|
|
459
492
|
payload: Dict[str, Any] = {"run": run}
|
|
460
493
|
if pre_dispatch is not None:
|
|
@@ -574,6 +607,7 @@ class AgentRuntime:
|
|
|
574
607
|
) -> Dict[str, Any]:
|
|
575
608
|
if not str(goal or "").strip():
|
|
576
609
|
raise ValueError("goal is required")
|
|
610
|
+
roles = self._validate_roles(roles)
|
|
577
611
|
|
|
578
612
|
pre_dispatch = self._fire_pre_run(
|
|
579
613
|
goal=goal,
|
|
@@ -21,7 +21,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
21
21
|
from .contracts import multi_agent_contract
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
MULTI_AGENT_VERSION = "8.
|
|
24
|
+
MULTI_AGENT_VERSION = "8.9.0"
|
|
25
25
|
|
|
26
26
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
27
27
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/chat.py
CHANGED
|
@@ -286,6 +286,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
286
286
|
group_history_conversations = context.group_history_conversations
|
|
287
287
|
get_conversation_messages = context.get_conversation_messages
|
|
288
288
|
conversation_title = context.conversation_title
|
|
289
|
+
allowed_workspaces_for = context.allowed_workspaces_for
|
|
289
290
|
|
|
290
291
|
CONFIG = context.config
|
|
291
292
|
CHAT_SERVICE = context.chat_service
|
|
@@ -314,6 +315,18 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
314
315
|
except Exception as exc:
|
|
315
316
|
logging.warning("chat message bridge failed: %s", exc)
|
|
316
317
|
|
|
318
|
+
def history_scope_for_user(user_email: Optional[str]) -> Dict:
|
|
319
|
+
require_auth = bool(getattr(CONFIG, "require_auth", False))
|
|
320
|
+
scoped_user = user_email if require_auth else None
|
|
321
|
+
allowed = None
|
|
322
|
+
if require_auth and scoped_user and allowed_workspaces_for is not None:
|
|
323
|
+
allowed = allowed_workspaces_for(scoped_user)
|
|
324
|
+
return {
|
|
325
|
+
"user_email": scoped_user,
|
|
326
|
+
"allowed_workspaces": allowed,
|
|
327
|
+
"include_legacy_global": not require_auth,
|
|
328
|
+
}
|
|
329
|
+
|
|
317
330
|
def recent_chat_context(
|
|
318
331
|
limit: int = 10,
|
|
319
332
|
include_image_missing_replies: bool = True,
|
|
@@ -449,14 +462,14 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
449
462
|
except Exception as e:
|
|
450
463
|
logging.warning("knowledge graph clear event ingest failed: %s", e)
|
|
451
464
|
if command == "/clear_all":
|
|
452
|
-
result = clear_history(0)
|
|
465
|
+
result = clear_history(0, **history_scope_for_user(effective_email))
|
|
453
466
|
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
454
467
|
else:
|
|
455
468
|
if req.conversation_id:
|
|
456
|
-
result = clear_conversation(req.conversation_id)
|
|
469
|
+
result = clear_conversation(req.conversation_id, **history_scope_for_user(effective_email))
|
|
457
470
|
answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
458
471
|
else:
|
|
459
|
-
result = clear_history(0)
|
|
472
|
+
result = clear_history(0, **history_scope_for_user(effective_email))
|
|
460
473
|
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
461
474
|
append_audit_event(
|
|
462
475
|
"clear_command",
|
|
@@ -791,20 +804,20 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
791
804
|
@api_router.get("/history")
|
|
792
805
|
async def fetch_history(request: Request):
|
|
793
806
|
"""웹 화면에서 이전 대화를 불러올 수 있도록 히스토리를 반환합니다."""
|
|
794
|
-
require_user(request)
|
|
795
|
-
return get_history()
|
|
807
|
+
current_user = require_user(request)
|
|
808
|
+
return get_history(**history_scope_for_user(current_user))
|
|
796
809
|
|
|
797
810
|
@api_router.get("/history/conversations")
|
|
798
811
|
async def fetch_history_conversations(request: Request):
|
|
799
812
|
"""저장된 히스토리를 대화 단위로 묶어 반환합니다."""
|
|
800
|
-
require_user(request)
|
|
801
|
-
return group_history_conversations()
|
|
813
|
+
current_user = require_user(request)
|
|
814
|
+
return group_history_conversations(get_history(**history_scope_for_user(current_user)))
|
|
802
815
|
|
|
803
816
|
@api_router.get("/history/conversations/{conversation_id:path}")
|
|
804
817
|
async def fetch_history_conversation(conversation_id: str, request: Request):
|
|
805
818
|
"""선택한 대화의 메시지를 반환합니다."""
|
|
806
|
-
require_user(request)
|
|
807
|
-
messages = get_conversation_messages(conversation_id)
|
|
819
|
+
current_user = require_user(request)
|
|
820
|
+
messages = get_conversation_messages(conversation_id, **history_scope_for_user(current_user))
|
|
808
821
|
if not messages:
|
|
809
822
|
raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
|
|
810
823
|
return {"id": conversation_id, "messages": messages}
|
|
@@ -814,7 +827,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
814
827
|
async def delete_history_conversation(conversation_id: str, request: Request):
|
|
815
828
|
"""선택한 대화방의 메시지만 삭제합니다."""
|
|
816
829
|
email = require_user(request)
|
|
817
|
-
result = clear_conversation(
|
|
830
|
+
result = clear_conversation(
|
|
831
|
+
conversation_id,
|
|
832
|
+
request.query_params.get("started_at"),
|
|
833
|
+
**history_scope_for_user(email),
|
|
834
|
+
)
|
|
818
835
|
append_audit_event(
|
|
819
836
|
"conversation_delete",
|
|
820
837
|
user_email=email,
|
|
@@ -829,7 +846,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
829
846
|
@api_router.delete("/history")
|
|
830
847
|
async def delete_history(request: Request, keep_last: int = 0):
|
|
831
848
|
email = require_user(request)
|
|
832
|
-
result = clear_history(keep_last)
|
|
849
|
+
result = clear_history(keep_last, **history_scope_for_user(email))
|
|
833
850
|
append_audit_event(
|
|
834
851
|
"history_delete",
|
|
835
852
|
user_email=email,
|
|
@@ -842,11 +859,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
842
859
|
@api_router.get("/history/search")
|
|
843
860
|
async def search_history(q: str, request: Request):
|
|
844
861
|
"""키워드로 채팅 히스토리를 검색합니다."""
|
|
845
|
-
require_user(request)
|
|
862
|
+
current_user = require_user(request)
|
|
846
863
|
if not q or not q.strip():
|
|
847
864
|
return {"results": [], "query": q}
|
|
848
865
|
q_lower = q.strip().lower()
|
|
849
|
-
history = get_history()
|
|
866
|
+
history = get_history(**history_scope_for_user(current_user))
|
|
850
867
|
matches = [item for item in history if q_lower in (item.get("content") or "").lower()]
|
|
851
868
|
grouped: Dict[str, Dict] = {}
|
|
852
869
|
for item in matches:
|
|
@@ -991,7 +1008,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
991
1008
|
}
|
|
992
1009
|
|
|
993
1010
|
# Auto-approve and run to completion (default behaviour)
|
|
994
|
-
_AGENT_RUNTIME.approve(ctx, current_user)
|
|
1011
|
+
_AGENT_RUNTIME.approve(ctx, current_user, approved_by_human=True)
|
|
995
1012
|
return await _agent_finish(ctx, req, lang_hint, current_user, max_steps, max_retry)
|
|
996
1013
|
|
|
997
1014
|
|
package/latticeai/api/mcp.py
CHANGED
|
@@ -30,6 +30,7 @@ from latticeai.core.mcp_registry import (
|
|
|
30
30
|
SKILLS_DIR,
|
|
31
31
|
)
|
|
32
32
|
from latticeai.core.tool_registry import MCP_TOOL_DESCRIPTIONS
|
|
33
|
+
from latticeai.services.tool_dispatch import enforce_tool_policy
|
|
33
34
|
from tools import AGENT_ROOT, execute_tool
|
|
34
35
|
|
|
35
36
|
|
|
@@ -392,7 +393,7 @@ def create_mcp_router(
|
|
|
392
393
|
args.get("limit", 6),
|
|
393
394
|
)
|
|
394
395
|
}
|
|
395
|
-
|
|
396
|
-
return _tool_response(execute_tool, req.action, req.args or {})
|
|
396
|
+
enforce_tool_policy(req.action, req.args or {}, current_user=current_user, source="mcp")
|
|
397
|
+
return _tool_response(execute_tool, req.action, req.args or {}, source="mcp")
|
|
397
398
|
|
|
398
399
|
return router
|
package/latticeai/api/models.py
CHANGED
|
@@ -67,6 +67,7 @@ class LoadModelRequest(BaseModel):
|
|
|
67
67
|
|
|
68
68
|
class InstallEngineRequest(BaseModel):
|
|
69
69
|
engine: str
|
|
70
|
+
confirmation_token: Optional[str] = None
|
|
70
71
|
|
|
71
72
|
|
|
72
73
|
class SetApiKeyRequest(BaseModel):
|
|
@@ -99,7 +100,7 @@ def create_models_router(
|
|
|
99
100
|
get_current_user: Callable[[Request], Optional[str]],
|
|
100
101
|
load_users: Callable[[], Dict],
|
|
101
102
|
get_user_role: Callable[..., str],
|
|
102
|
-
install_engine: Callable[
|
|
103
|
+
install_engine: Callable[..., Dict],
|
|
103
104
|
verify_cloud_models: Callable[..., Any],
|
|
104
105
|
normalize_local_model_request: Callable[..., str],
|
|
105
106
|
download_hf_model: Callable[..., Dict],
|
|
@@ -281,6 +282,8 @@ def create_models_router(
|
|
|
281
282
|
@router.post("/engines/install")
|
|
282
283
|
async def engines_install(req: InstallEngineRequest, request: Request):
|
|
283
284
|
require_user(request)
|
|
285
|
+
if req.confirmation_token:
|
|
286
|
+
return install_engine(req.engine, confirmation_token=req.confirmation_token)
|
|
284
287
|
return install_engine(req.engine)
|
|
285
288
|
|
|
286
289
|
@router.post("/engines/verify-cloud")
|