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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Lattice AI Workspace OS for local-first graph, memory, agent, workflow, and skill operations",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {