ltcai 1.1.0 → 1.3.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 +31 -0
- package/docs/CHANGELOG.md +90 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/health.py +45 -0
- package/latticeai/api/mcp.py +386 -0
- package/latticeai/api/models.py +307 -0
- package/latticeai/api/workspace.py +748 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +117 -1321
- package/latticeai/services/__init__.py +6 -0
- package/latticeai/services/chat_service.py +53 -0
- package/latticeai/services/model_service.py +51 -0
- package/latticeai/services/workspace_service.py +117 -0
- package/package.json +1 -1
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Service layer extracted from server_app.py.
|
|
2
|
+
|
|
3
|
+
Services wrap stores and business logic with no dependency on the FastAPI app
|
|
4
|
+
object, so API routers (latticeai.api.*) and the app assembly
|
|
5
|
+
(latticeai.server_app) can import them without creating import cycles.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Chat / session service seam.
|
|
2
|
+
|
|
3
|
+
The streaming chat path in ``server_app`` is intentionally left in place — its
|
|
4
|
+
generator and SSE behaviour are sensitive. This service provides a stable seam
|
|
5
|
+
for the *bookkeeping* around answers (conversation history access and
|
|
6
|
+
Workspace-OS answer-trace recording) so those concerns are named and wrapped
|
|
7
|
+
rather than reaching into the store directly from the streaming handler.
|
|
8
|
+
|
|
9
|
+
It is a behaviour-preserving façade: methods forward to the injected history
|
|
10
|
+
accessor and :class:`WorkspaceOSStore`, so wiring the chat path through it
|
|
11
|
+
cannot change streaming output.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from latticeai.core.workspace_os import WorkspaceOSStore
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChatService:
|
|
22
|
+
def __init__(self, *, store: WorkspaceOSStore, get_history: Callable[[], List[Dict[str, Any]]]):
|
|
23
|
+
self._store = store
|
|
24
|
+
self._get_history = get_history
|
|
25
|
+
|
|
26
|
+
# ── conversation history ─────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
def history(self) -> List[Dict[str, Any]]:
|
|
29
|
+
return self._get_history()
|
|
30
|
+
|
|
31
|
+
# ── answer-trace recording (Graph RAG) ───────────────────────────────
|
|
32
|
+
|
|
33
|
+
def build_graph_trace(self, question: str, graph: Any, context: str = "", *, limit: int = 8) -> Dict[str, Any]:
|
|
34
|
+
return self._store.build_graph_trace(question, graph, context, limit=limit)
|
|
35
|
+
|
|
36
|
+
def record_trace(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
question: str,
|
|
40
|
+
response: str,
|
|
41
|
+
conversation_id: Optional[str],
|
|
42
|
+
user_email: Optional[str],
|
|
43
|
+
trace: Dict[str, Any],
|
|
44
|
+
workspace_id: Optional[str] = None,
|
|
45
|
+
) -> Dict[str, Any]:
|
|
46
|
+
return self._store.record_trace(
|
|
47
|
+
question=question,
|
|
48
|
+
response=response,
|
|
49
|
+
conversation_id=conversation_id,
|
|
50
|
+
user_email=user_email,
|
|
51
|
+
trace=trace,
|
|
52
|
+
workspace_id=workspace_id,
|
|
53
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Model / engine state helpers used by the health and model routers.
|
|
2
|
+
|
|
3
|
+
Pure payload builders with no FastAPI dependency. The model runtime (LLMRouter),
|
|
4
|
+
``engine_status``, and ``runtime_features`` remain owned by ``server_app``; this
|
|
5
|
+
module just assembles the response shapes so the health/model routers stay thin
|
|
6
|
+
and the summaries live in one place.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Callable, Dict, List
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ModelService:
|
|
15
|
+
"""Assembles health/engine summary payloads from injected runtime pieces."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
model_router: Any,
|
|
21
|
+
runtime_features: Callable[[], Dict[str, Any]],
|
|
22
|
+
is_public: bool,
|
|
23
|
+
):
|
|
24
|
+
self._router = model_router
|
|
25
|
+
self._runtime_features = runtime_features
|
|
26
|
+
self._is_public = is_public
|
|
27
|
+
|
|
28
|
+
def runtime(self) -> Dict[str, Any]:
|
|
29
|
+
return self._runtime_features()
|
|
30
|
+
|
|
31
|
+
def health_base(self, *, version: str, mode: str) -> Dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"status": "ok",
|
|
34
|
+
"version": version,
|
|
35
|
+
"mode": mode,
|
|
36
|
+
"platform": "AI Workspace OS",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def health_full(self, base: Dict[str, Any], engines: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
**base,
|
|
42
|
+
"current_model": self._router.current_model_id,
|
|
43
|
+
"loaded_models": self._router.loaded_model_ids,
|
|
44
|
+
"device": "Apple Silicon MLX" if not self._is_public else "Public cloud/API runtime",
|
|
45
|
+
"features": self._runtime_features(),
|
|
46
|
+
"providers": self._router.detected_cloud_models(),
|
|
47
|
+
"engines": engines,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def engines_payload(self, engines: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
51
|
+
return {"engines": engines, "current": self._router.current_model_id}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Workspace service layer: scope resolution, permission guardrails, and a
|
|
2
|
+
single seam in front of :class:`WorkspaceOSStore`.
|
|
3
|
+
|
|
4
|
+
This module centralizes the workspace_id resolution and role/permission checks
|
|
5
|
+
that were previously scattered across the FastAPI handlers. It depends only on
|
|
6
|
+
``workspace_os`` (and indirectly ``enterprise``), never on the FastAPI app, so
|
|
7
|
+
both the app assembly and the API routers can import it freely.
|
|
8
|
+
|
|
9
|
+
Guardrail summary (v1.2.0):
|
|
10
|
+
|
|
11
|
+
* **Explicit workspace targeting is gated.** When a caller names a workspace
|
|
12
|
+
(``X-Workspace-Id`` header / ``workspace_id``), reads require ``read`` and
|
|
13
|
+
writes require ``write`` on that workspace; non-members are rejected.
|
|
14
|
+
* **Backward compatible default.** With no workspace named, the scope resolves
|
|
15
|
+
to the *active* workspace (Personal by default). Pre-1.1 clients that never
|
|
16
|
+
send a header keep operating on Personal data exactly as before.
|
|
17
|
+
* **Personal workspace** always grants its single local user owner rights.
|
|
18
|
+
* **No-auth local mode** keeps the owner fallback for *ownerless* org
|
|
19
|
+
workspaces (the anonymous local user owns what they create), but a *named*
|
|
20
|
+
stranger never bypasses membership.
|
|
21
|
+
* **Graph and installed Skills are intentionally machine-global shared state**
|
|
22
|
+
(the local knowledge graph and on-disk skills are not partitioned per
|
|
23
|
+
workspace). Skill enable/disable and other actions still record
|
|
24
|
+
workspace-scoped timeline events via the active workspace.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Any, Dict, Optional
|
|
30
|
+
|
|
31
|
+
from latticeai.core.workspace_os import WorkspaceOSStore
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WorkspaceService:
|
|
35
|
+
"""Permission-aware façade over :class:`WorkspaceOSStore`."""
|
|
36
|
+
|
|
37
|
+
# Graph / installed-skill state that is shared machine-wide rather than
|
|
38
|
+
# partitioned per workspace. Surfaced so the UI / docs can be explicit.
|
|
39
|
+
SHARED_GLOBAL_AREAS = ("graph", "skills")
|
|
40
|
+
|
|
41
|
+
def __init__(self, store: WorkspaceOSStore):
|
|
42
|
+
self.store = store
|
|
43
|
+
|
|
44
|
+
# ── scope resolution + gating ────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
def _ensure_permission(self, workspace_id: str, user_id: Optional[str], permission: str) -> None:
|
|
47
|
+
if not self.store.has_permission(workspace_id, user_id, permission):
|
|
48
|
+
raise PermissionError(
|
|
49
|
+
f"'{user_id or 'anonymous'}' lacks '{permission}' on workspace '{workspace_id}'"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def resolve_read_scope(self, requested: Optional[str], user_id: Optional[str]) -> str:
|
|
53
|
+
"""Resolve + authorize the workspace a read should target.
|
|
54
|
+
|
|
55
|
+
``None`` falls back to the active workspace (Personal by default), which
|
|
56
|
+
preserves pre-1.1 behaviour. An explicitly named workspace is gated on
|
|
57
|
+
``read`` so non-members cannot read organization data.
|
|
58
|
+
"""
|
|
59
|
+
workspace_id = requested or self.store._active_workspace_id()
|
|
60
|
+
self._ensure_permission(workspace_id, user_id, "read")
|
|
61
|
+
return workspace_id
|
|
62
|
+
|
|
63
|
+
def resolve_write_scope(self, requested: Optional[str], user_id: Optional[str]) -> str:
|
|
64
|
+
"""Resolve + authorize the workspace a write should target (gated on ``write``)."""
|
|
65
|
+
workspace_id = requested or self.store._active_workspace_id()
|
|
66
|
+
self._ensure_permission(workspace_id, user_id, "write")
|
|
67
|
+
return workspace_id
|
|
68
|
+
|
|
69
|
+
def can_read(self, workspace_id: str, user_id: Optional[str]) -> bool:
|
|
70
|
+
return self.store.has_permission(workspace_id, user_id, "read")
|
|
71
|
+
|
|
72
|
+
def can_write(self, workspace_id: str, user_id: Optional[str]) -> bool:
|
|
73
|
+
return self.store.has_permission(workspace_id, user_id, "write")
|
|
74
|
+
|
|
75
|
+
# ── workspace registry / summary ─────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def summary(self, user_id: Optional[str]) -> Dict[str, Any]:
|
|
78
|
+
data = self.store.summary()
|
|
79
|
+
data["workspace_registry"] = self.store.list_workspaces(user_id=user_id)
|
|
80
|
+
data["shared_global_areas"] = list(self.SHARED_GLOBAL_AREAS)
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
def list_workspaces(self, user_id: Optional[str]) -> Dict[str, Any]:
|
|
84
|
+
return self.store.list_workspaces(user_id=user_id)
|
|
85
|
+
|
|
86
|
+
def get_workspace(self, workspace_id: str, user_id: Optional[str]) -> Dict[str, Any]:
|
|
87
|
+
# Reading workspace metadata requires read access to that workspace.
|
|
88
|
+
self._ensure_permission(workspace_id, user_id, "read")
|
|
89
|
+
return self.store.get_workspace(workspace_id, user_id=user_id)
|
|
90
|
+
|
|
91
|
+
def workspace_summary(self, workspace_id: str, user_id: Optional[str]) -> Dict[str, Any]:
|
|
92
|
+
self._ensure_permission(workspace_id, user_id, "read")
|
|
93
|
+
return self.store.workspace_summary(workspace_id, user_id=user_id)
|
|
94
|
+
|
|
95
|
+
# ── organization workspace management (delegates with actor) ─────────
|
|
96
|
+
|
|
97
|
+
def create_organization_workspace(self, *, name: str, owner_user_id: Optional[str], settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
98
|
+
return self.store.create_organization_workspace(name=name, owner_user_id=owner_user_id, settings=settings)
|
|
99
|
+
|
|
100
|
+
def update_workspace(self, workspace_id: str, *, name=None, settings=None, actor=None) -> Dict[str, Any]:
|
|
101
|
+
return self.store.update_workspace(workspace_id, name=name, settings=settings, actor=actor)
|
|
102
|
+
|
|
103
|
+
def archive_workspace(self, workspace_id: str, *, actor=None) -> Dict[str, Any]:
|
|
104
|
+
return self.store.archive_workspace(workspace_id, actor=actor)
|
|
105
|
+
|
|
106
|
+
def add_member(self, workspace_id: str, *, user_id: str, role: str = "member", actor=None) -> Dict[str, Any]:
|
|
107
|
+
return self.store.add_member(workspace_id, user_id=user_id, role=role, actor=actor)
|
|
108
|
+
|
|
109
|
+
def update_member_role(self, workspace_id: str, *, user_id: str, role: str, actor=None) -> Dict[str, Any]:
|
|
110
|
+
return self.store.update_member_role(workspace_id, user_id=user_id, role=role, actor=actor)
|
|
111
|
+
|
|
112
|
+
def remove_member(self, workspace_id: str, *, user_id: str, actor=None) -> Dict[str, Any]:
|
|
113
|
+
return self.store.remove_member(workspace_id, user_id=user_id, actor=actor)
|
|
114
|
+
|
|
115
|
+
def set_active_workspace(self, workspace_id: str, user_id: Optional[str]) -> Dict[str, Any]:
|
|
116
|
+
# Membership is enforced inside the store for organization workspaces.
|
|
117
|
+
return self.store.set_active_workspace(workspace_id, user_id=user_id)
|
package/package.json
CHANGED