ltcai 2.2.7 → 3.0.1

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 (69) hide show
  1. package/README.md +63 -32
  2. package/docs/CHANGELOG.md +82 -0
  3. package/docs/V3_BACKEND_ARCHITECTURE.md +138 -0
  4. package/docs/V3_FRONTEND.md +136 -0
  5. package/knowledge_graph.py +649 -21
  6. package/latticeai/__init__.py +1 -1
  7. package/latticeai/api/admin.py +47 -0
  8. package/latticeai/api/agents.py +54 -31
  9. package/latticeai/api/auth.py +1 -1
  10. package/latticeai/api/chat.py +10 -2
  11. package/latticeai/api/search.py +236 -0
  12. package/latticeai/api/static_routes.py +11 -2
  13. package/latticeai/core/config.py +16 -0
  14. package/latticeai/core/embedding_providers.py +502 -0
  15. package/latticeai/core/local_embeddings.py +86 -0
  16. package/latticeai/core/workspace_os.py +1 -1
  17. package/latticeai/server_app.py +49 -1
  18. package/latticeai/services/agent_runtime.py +245 -0
  19. package/latticeai/services/search_service.py +346 -0
  20. package/package.json +6 -4
  21. package/static/account.html +9 -9
  22. package/static/activity.html +4 -4
  23. package/static/admin.html +8 -8
  24. package/static/agents.html +4 -4
  25. package/static/chat.html +10 -10
  26. package/static/css/reference/account.css +137 -1
  27. package/static/css/reference/chat.css +31 -37
  28. package/static/css/responsive.css +42 -0
  29. package/static/css/tokens.css +125 -130
  30. package/static/graph.html +9 -9
  31. package/static/manifest.json +3 -3
  32. package/static/plugins.html +4 -4
  33. package/static/scripts/account.js +4 -4
  34. package/static/scripts/chat.js +40 -8
  35. package/static/scripts/workspace.js +78 -0
  36. package/static/v3/css/lattice.base.css +128 -0
  37. package/static/v3/css/lattice.components.css +447 -0
  38. package/static/v3/css/lattice.shell.css +407 -0
  39. package/static/v3/css/lattice.tokens.css +132 -0
  40. package/static/v3/css/lattice.views.css +277 -0
  41. package/static/v3/index.html +40 -0
  42. package/static/v3/js/app.js +26 -0
  43. package/static/v3/js/core/api.js +327 -0
  44. package/static/v3/js/core/components.js +215 -0
  45. package/static/v3/js/core/dom.js +148 -0
  46. package/static/v3/js/core/fixtures.js +171 -0
  47. package/static/v3/js/core/router.js +37 -0
  48. package/static/v3/js/core/routes.js +73 -0
  49. package/static/v3/js/core/shell.js +363 -0
  50. package/static/v3/js/core/store.js +113 -0
  51. package/static/v3/js/views/admin-audit.js +185 -0
  52. package/static/v3/js/views/admin-permissions.js +178 -0
  53. package/static/v3/js/views/admin-policies.js +103 -0
  54. package/static/v3/js/views/admin-private-vpc.js +138 -0
  55. package/static/v3/js/views/admin-security.js +181 -0
  56. package/static/v3/js/views/admin-users.js +168 -0
  57. package/static/v3/js/views/agents.js +194 -0
  58. package/static/v3/js/views/chat.js +450 -0
  59. package/static/v3/js/views/files.js +180 -0
  60. package/static/v3/js/views/home.js +119 -0
  61. package/static/v3/js/views/hybrid-search.js +195 -0
  62. package/static/v3/js/views/knowledge-graph.js +238 -0
  63. package/static/v3/js/views/models.js +247 -0
  64. package/static/v3/js/views/my-computer.js +237 -0
  65. package/static/v3/js/views/pipeline.js +161 -0
  66. package/static/v3/js/views/settings.js +258 -0
  67. package/static/workflows.html +4 -4
  68. package/static/workspace.css +340 -2
  69. package/static/workspace.html +43 -24
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "2.2.7"
3
+ __version__ = "3.0.1"
@@ -109,6 +109,53 @@ def create_admin_router(
109
109
  report["graph"] = {"error": str(e)}
110
110
  return report
111
111
 
112
+ # Canonical RBAC capability map — which product areas each role can reach.
113
+ # This is the real access policy the app enforces, not sample data.
114
+ _ROLE_CAPS = {
115
+ "owner": ["all"],
116
+ "admin": ["users", "policies", "audit", "security", "chat", "search", "files", "pipeline"],
117
+ "member": ["chat", "search", "files", "pipeline"],
118
+ "user": ["chat", "search", "files", "pipeline"],
119
+ "viewer": ["chat", "search"],
120
+ }
121
+
122
+ @router.get("/admin/roles")
123
+ async def admin_roles(request: Request):
124
+ _, users = require_admin(request)
125
+ counts: Dict[str, int] = defaultdict(int)
126
+ for email, user in users.items():
127
+ role = (get_user_role(email, users) or "user").lower()
128
+ counts[role] += 1
129
+ # Always surface the two RBAC tiers the app actually distinguishes so the
130
+ # matrix is meaningful even before extra users exist.
131
+ for base in ("admin", "user"):
132
+ counts.setdefault(base, counts.get(base, 0))
133
+ roles = [
134
+ {"role": role, "members": counts.get(role, 0), "caps": _ROLE_CAPS.get(role, ["chat", "search"])}
135
+ for role in sorted(counts, key=lambda r: (r != "owner", r != "admin", r))
136
+ ]
137
+ return {"roles": roles}
138
+
139
+ @router.get("/admin/policies")
140
+ async def admin_policies(request: Request):
141
+ require_admin(request)
142
+ # The real, enforced governance posture of a local-first deployment.
143
+ return {
144
+ "policies": [
145
+ {"id": "local_file_access", "label": "Local file access",
146
+ "value": "Approval-token gated (per path/user/action)", "enforced": True},
147
+ {"id": "package_install", "label": "Package install",
148
+ "value": "Admin-only with audit trail", "enforced": True},
149
+ {"id": "data_residency", "label": "Data residency",
150
+ "value": "Single-tenant local storage (~/.ltcai)", "enforced": True},
151
+ {"id": "model_egress", "label": "Model egress",
152
+ "value": "Local-only by default (no external inference in local mode)", "enforced": True},
153
+ {"id": "invite_gate", "label": "Invite gate",
154
+ "value": "Required for new accounts" if invite_gate_enabled else "Open registration",
155
+ "enforced": bool(invite_gate_enabled)},
156
+ ]
157
+ }
158
+
112
159
  @router.get("/vpc/status")
113
160
  async def vpc_status(request: Request):
114
161
  require_user(request)
@@ -40,11 +40,54 @@ def create_agents_router(
40
40
  append_audit_event: Callable[..., None],
41
41
  ui_file_response: Optional[Callable[[Path], Any]] = None,
42
42
  static_dir: Optional[Path] = None,
43
+ agent_runtime: Any = None,
43
44
  ) -> APIRouter:
44
45
  from latticeai.core.multi_agent import AGENT_ROLES, ROLE_AGENT_IDS
46
+ from latticeai.services.agent_runtime import AgentRuntime
47
+
48
+ # Single AgentRuntime boundary: the router (and via it, the frontend) talks
49
+ # to this façade instead of reaching into the orchestrator/store directly.
50
+ runtime = agent_runtime or AgentRuntime(
51
+ store=store,
52
+ orchestrator_factory=orchestrator_factory,
53
+ workspace_graph=workspace_graph,
54
+ append_audit_event=append_audit_event,
55
+ )
45
56
 
46
57
  router = APIRouter()
47
58
 
59
+ # ── AgentRuntime boundary endpoints ───────────────────────────────────
60
+ @router.get("/agents/api/runtime/status")
61
+ async def agent_runtime_status(request: Request):
62
+ require_user(request)
63
+ scope = gate_read(request)
64
+ return runtime.status(scope=scope)
65
+
66
+ @router.get("/agents/api/runtime/health")
67
+ async def agent_runtime_health(request: Request):
68
+ require_user(request)
69
+ return runtime.health()
70
+
71
+ @router.get("/agents/api/runtime/config")
72
+ async def agent_runtime_config(request: Request):
73
+ require_user(request)
74
+ return runtime.config()
75
+
76
+ @router.get("/agents/api/runs/{run_id}/events")
77
+ async def agent_run_events(run_id: str, request: Request):
78
+ require_user(request)
79
+ scope = gate_read(request)
80
+ try:
81
+ return runtime.events(run_id, scope=scope)
82
+ except FileNotFoundError as exc:
83
+ raise HTTPException(status_code=404, detail=f"Agent run not found: {run_id}") from exc
84
+
85
+ @router.post("/agents/api/runs/{run_id}/stop")
86
+ async def agent_run_stop(run_id: str, request: Request):
87
+ require_user(request)
88
+ scope = gate_write(request)
89
+ return runtime.stop(run_id, scope=scope)
90
+
48
91
  @router.get("/agents")
49
92
  async def agents_page(request: Request):
50
93
  require_user(request)
@@ -119,36 +162,16 @@ def create_agents_router(
119
162
  async def agent_run(req: AgentRunRequest, request: Request):
120
163
  current_user = require_user(request)
121
164
  scope = gate_write(request)
122
- if not str(req.goal or "").strip():
123
- raise HTTPException(status_code=400, detail="goal is required")
124
- orchestrator = orchestrator_factory(current_user or None, scope)
125
- result = orchestrator.run(
126
- req.goal,
127
- user_email=current_user or None,
128
- workspace_id=scope,
129
- inputs=req.inputs,
130
- roles=req.roles or None,
131
- max_retries=max(0, min(int(req.max_retries or 0), 5)),
132
- )
133
- run = store.record_agent_run(
134
- agent_id=result.agent_id,
135
- status=result.status,
136
- input_text=req.goal,
137
- output_text=result.output,
138
- timeline=result.timeline,
139
- relationships=[ROLE_AGENT_IDS.get(r, f"agent:{r}") for r in result.roles_run],
140
- handoffs=result.handoffs,
141
- context_packets=result.context_packets,
142
- plan=result.plan,
143
- plan_review=result.plan_review,
144
- review_history=result.review_history,
145
- retry_history=result.retry_history,
146
- memory_snapshots=result.memory_snapshots,
147
- user_email=current_user or None,
148
- graph=workspace_graph(),
149
- workspace_id=scope,
150
- )
151
- append_audit_event("multi_agent_run", user_email=current_user, agent_id=result.agent_id, status=result.status, retries=result.retries)
152
- return {"run": run, "result": result.as_dict()}
165
+ try:
166
+ return runtime.start(
167
+ req.goal,
168
+ user_email=current_user or None,
169
+ scope=scope,
170
+ roles=req.roles or None,
171
+ inputs=req.inputs,
172
+ max_retries=req.max_retries,
173
+ )
174
+ except ValueError as exc:
175
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
153
176
 
154
177
  return router
@@ -167,7 +167,7 @@ def create_auth_router(
167
167
  if users[email].get("disabled"):
168
168
  raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
169
169
  token = create_session(email)
170
- resp = RedirectResponse("/chat", status_code=302)
170
+ resp = RedirectResponse("/app", status_code=302)
171
171
  resp.set_cookie("session_token", token, httponly=True, samesite="lax", max_age=session_ttl)
172
172
  return resp
173
173
 
@@ -342,9 +342,17 @@ def create_chat_router(
342
342
 
343
343
  if not router.current_model_id:
344
344
  detail = "No model loaded. Call /models/load first."
345
- if IS_PUBLIC_MODE:
345
+ if CONFIG.is_public:
346
346
  detail = f"No public model loaded. Set OPENAI_API_KEY and LATTICEAI_PUBLIC_MODEL={PUBLIC_MODEL}, or call /models/load with an OpenAI-compatible model."
347
- raise HTTPException(status_code=400, detail=detail)
347
+ return JSONResponse(
348
+ status_code=400,
349
+ content={
350
+ "error": "no_model_loaded",
351
+ "detail": detail,
352
+ "message": detail,
353
+ "action": "load_model",
354
+ },
355
+ )
348
356
 
349
357
  if req.model and req.model != router.current_model_id:
350
358
  if req.model not in router.loaded_model_ids:
@@ -0,0 +1,236 @@
1
+ """v3 knowledge graph, vector, and hybrid search API contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict, Optional
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+ from pydantic import BaseModel, Field
9
+
10
+ from latticeai.services.search_service import DEFAULT_HYBRID_WEIGHTS, SearchService
11
+
12
+
13
+ class SearchRequest(BaseModel):
14
+ query: str = Field(..., min_length=1)
15
+ limit: int = 30
16
+
17
+
18
+ class VectorSearchRequest(SearchRequest):
19
+ min_score: float = 0.0
20
+
21
+
22
+ class HybridSearchRequest(SearchRequest):
23
+ keyword_limit: int = 30
24
+ vector_limit: int = 30
25
+ graph_limit: int = 30
26
+ weights: Optional[Dict[str, float]] = None
27
+
28
+
29
+ class GraphNodeRequest(BaseModel):
30
+ node_id: str = Field(..., min_length=1)
31
+ include_neighbors: bool = True
32
+ depth: int = 1
33
+ limit: int = 100
34
+
35
+
36
+ class RelationshipSearchRequest(BaseModel):
37
+ query: str = ""
38
+ node_id: str = ""
39
+ relationship_type: str = ""
40
+ limit: int = 30
41
+
42
+
43
+ class IndexRebuildRequest(BaseModel):
44
+ full: bool = False
45
+ include_nodes: bool = True
46
+ include_chunks: bool = True
47
+
48
+
49
+ def create_search_router(
50
+ *,
51
+ service: SearchService,
52
+ require_user: Callable[[Request], str],
53
+ embedding_info: Optional[Callable[[], Dict[str, Any]]] = None,
54
+ ) -> APIRouter:
55
+ router = APIRouter()
56
+
57
+ def _guarded(request: Request) -> SearchService:
58
+ require_user(request)
59
+ return service
60
+
61
+ def _raise_graph_error(exc: Exception) -> None:
62
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
63
+
64
+ @router.post("/api/search/hybrid")
65
+ async def hybrid_search(req: HybridSearchRequest, request: Request) -> Dict[str, Any]:
66
+ try:
67
+ return _guarded(request).hybrid_search(
68
+ req.query,
69
+ limit=req.limit,
70
+ keyword_limit=req.keyword_limit,
71
+ vector_limit=req.vector_limit,
72
+ graph_limit=req.graph_limit,
73
+ weights=req.weights or DEFAULT_HYBRID_WEIGHTS,
74
+ )
75
+ except ValueError as exc:
76
+ _raise_graph_error(exc)
77
+
78
+ @router.get("/api/search/hybrid")
79
+ async def hybrid_search_get(q: str, request: Request, limit: int = 30) -> Dict[str, Any]:
80
+ try:
81
+ return _guarded(request).hybrid_search(q, limit=limit)
82
+ except ValueError as exc:
83
+ _raise_graph_error(exc)
84
+
85
+ @router.post("/api/search/keyword")
86
+ async def keyword_search(req: SearchRequest, request: Request) -> Dict[str, Any]:
87
+ try:
88
+ return _guarded(request).keyword_search(req.query, limit=req.limit)
89
+ except ValueError as exc:
90
+ _raise_graph_error(exc)
91
+
92
+ @router.get("/api/search/keyword")
93
+ async def keyword_search_get(q: str, request: Request, limit: int = 30) -> Dict[str, Any]:
94
+ try:
95
+ return _guarded(request).keyword_search(q, limit=limit)
96
+ except ValueError as exc:
97
+ _raise_graph_error(exc)
98
+
99
+ @router.post("/api/search/vector")
100
+ async def vector_search(req: VectorSearchRequest, request: Request) -> Dict[str, Any]:
101
+ try:
102
+ return _guarded(request).vector_search(req.query, limit=req.limit, min_score=req.min_score)
103
+ except ValueError as exc:
104
+ _raise_graph_error(exc)
105
+
106
+ @router.get("/api/search/vector")
107
+ async def vector_search_get(
108
+ q: str,
109
+ request: Request,
110
+ limit: int = 30,
111
+ min_score: float = 0.0,
112
+ ) -> Dict[str, Any]:
113
+ try:
114
+ return _guarded(request).vector_search(q, limit=limit, min_score=min_score)
115
+ except ValueError as exc:
116
+ _raise_graph_error(exc)
117
+
118
+ @router.get("/api/graph")
119
+ async def graph(request: Request, limit: int = 300) -> Dict[str, Any]:
120
+ try:
121
+ return _guarded(request).graph(limit=limit)
122
+ except ValueError as exc:
123
+ _raise_graph_error(exc)
124
+
125
+ @router.get("/api/graph/node")
126
+ async def graph_node(
127
+ node_id: str,
128
+ request: Request,
129
+ include_neighbors: bool = True,
130
+ depth: int = 1,
131
+ limit: int = 100,
132
+ ) -> Dict[str, Any]:
133
+ try:
134
+ return _guarded(request).node(
135
+ node_id,
136
+ include_neighbors=include_neighbors,
137
+ depth=depth,
138
+ limit=limit,
139
+ )
140
+ except ValueError as exc:
141
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
142
+
143
+ @router.post("/api/graph/node")
144
+ async def graph_node_post(req: GraphNodeRequest, request: Request) -> Dict[str, Any]:
145
+ try:
146
+ return _guarded(request).node(
147
+ req.node_id,
148
+ include_neighbors=req.include_neighbors,
149
+ depth=req.depth,
150
+ limit=req.limit,
151
+ )
152
+ except ValueError as exc:
153
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
154
+
155
+ @router.get("/api/graph/relationship")
156
+ async def graph_relationship(
157
+ request: Request,
158
+ q: str = "",
159
+ node_id: str = "",
160
+ relationship_type: str = "",
161
+ limit: int = 30,
162
+ ) -> Dict[str, Any]:
163
+ try:
164
+ return _guarded(request).relationships(
165
+ query=q,
166
+ node_id=node_id,
167
+ relationship_type=relationship_type,
168
+ limit=limit,
169
+ )
170
+ except ValueError as exc:
171
+ _raise_graph_error(exc)
172
+
173
+ @router.post("/api/graph/relationship")
174
+ async def graph_relationship_post(req: RelationshipSearchRequest, request: Request) -> Dict[str, Any]:
175
+ try:
176
+ return _guarded(request).relationships(
177
+ query=req.query,
178
+ node_id=req.node_id,
179
+ relationship_type=req.relationship_type,
180
+ limit=req.limit,
181
+ )
182
+ except ValueError as exc:
183
+ _raise_graph_error(exc)
184
+
185
+ @router.get("/api/index/status")
186
+ async def index_status(request: Request) -> Dict[str, Any]:
187
+ try:
188
+ return _guarded(request).index_status()
189
+ except ValueError as exc:
190
+ _raise_graph_error(exc)
191
+
192
+ @router.post("/api/index/rebuild")
193
+ async def index_rebuild(req: IndexRebuildRequest, request: Request) -> Dict[str, Any]:
194
+ try:
195
+ return _guarded(request).rebuild_index(
196
+ full=req.full,
197
+ include_nodes=req.include_nodes,
198
+ include_chunks=req.include_chunks,
199
+ )
200
+ except ValueError as exc:
201
+ _raise_graph_error(exc)
202
+
203
+ @router.get("/api/embeddings/status")
204
+ async def embeddings_status(request: Request, refresh: bool = False) -> Dict[str, Any]:
205
+ require_user(request)
206
+ resolved = embedding_info() if embedding_info else {}
207
+ try:
208
+ return service.embeddings_status(resolved=resolved, refresh=refresh)
209
+ except ValueError as exc:
210
+ _raise_graph_error(exc)
211
+
212
+ @router.get("/api/embeddings/providers")
213
+ async def embeddings_providers(request: Request) -> Dict[str, Any]:
214
+ require_user(request)
215
+ resolved = embedding_info() if embedding_info else {}
216
+ return {
217
+ "active": resolved.get("active_provider"),
218
+ "requested": resolved.get("requested_provider"),
219
+ "providers": [
220
+ {"id": "hash", "label": "Local hash (fallback)", "grade": "fallback",
221
+ "requires": [], "detail": "Deterministic offline vectors — always available."},
222
+ {"id": "mlx", "label": "MLX (Apple Silicon)", "grade": "production",
223
+ "requires": ["LATTICEAI_EMBEDDING_MODEL"], "detail": "Local embedding model via MLX."},
224
+ {"id": "ollama", "label": "Ollama", "grade": "production",
225
+ "requires": ["LATTICEAI_EMBEDDING_MODEL", "LATTICEAI_EMBEDDING_BASE_URL"],
226
+ "detail": "Local/remote Ollama embedding server."},
227
+ {"id": "openai", "label": "OpenAI-compatible", "grade": "production",
228
+ "requires": ["LATTICEAI_EMBEDDING_MODEL", "LATTICEAI_EMBEDDING_BASE_URL", "LATTICEAI_EMBEDDING_API_KEY"],
229
+ "detail": "Any /v1/embeddings endpoint (OpenAI, LM Studio, vLLM, …)."},
230
+ {"id": "custom", "label": "Custom callable", "grade": "production",
231
+ "requires": ["LATTICEAI_EMBEDDING_CUSTOM_TARGET"],
232
+ "detail": "User-supplied module:callable returning vectors."},
233
+ ],
234
+ }
235
+
236
+ return router
@@ -107,8 +107,17 @@ def create_static_routes_router(
107
107
  @api_router.get("/chat")
108
108
  async def chat_page(request: Request):
109
109
  return ui_file_response(STATIC_DIR / "chat.html")
110
-
111
-
110
+
111
+
112
+ @api_router.get("/app")
113
+ async def app_shell(request: Request):
114
+ """v3 single-page workspace shell (token-native design system)."""
115
+ page = STATIC_DIR / "v3" / "index.html"
116
+ if not page.exists():
117
+ raise HTTPException(status_code=404, detail="v3 shell not found.")
118
+ return ui_file_response(page)
119
+
120
+
112
121
  @api_router.get("/admin")
113
122
  async def admin_page():
114
123
  admin_path = STATIC_DIR / "admin.html"
@@ -93,6 +93,15 @@ class Config:
93
93
  local_draft_model: str
94
94
  auto_read_chat_paths: bool
95
95
 
96
+ # ── embeddings (retrieval vector signal) ────────────────────────
97
+ embedding_provider: str
98
+ embedding_model: str
99
+ embedding_base_url: str
100
+ embedding_api_key: str
101
+ embedding_dim: int
102
+ embedding_timeout: int
103
+ embedding_custom_target: str
104
+
96
105
  # ── SSO / OIDC ──────────────────────────────────────────────────
97
106
  sso_discovery_url: str
98
107
  sso_client_id: str
@@ -164,6 +173,13 @@ class Config:
164
173
  local_model=local_model,
165
174
  local_draft_model=_value(env, "LATTICEAI_LOCAL_DRAFT_MODEL", ""),
166
175
  auto_read_chat_paths=_bool(env, "LATTICEAI_AUTO_READ_CHAT_PATHS", default=False),
176
+ embedding_provider=_value(env, "LATTICEAI_EMBEDDING_PROVIDER", "hash").strip().lower(),
177
+ embedding_model=_value(env, "LATTICEAI_EMBEDDING_MODEL", ""),
178
+ embedding_base_url=_value(env, "LATTICEAI_EMBEDDING_BASE_URL", ""),
179
+ embedding_api_key=_value(env, "LATTICEAI_EMBEDDING_API_KEY", ""),
180
+ embedding_dim=_int(env, "LATTICEAI_VECTOR_DIM", 0),
181
+ embedding_timeout=_int(env, "LATTICEAI_EMBEDDING_TIMEOUT", 30),
182
+ embedding_custom_target=_value(env, "LATTICEAI_EMBEDDING_CUSTOM_TARGET", ""),
167
183
  sso_discovery_url=_value(env, "OIDC_DISCOVERY_URL", ""),
168
184
  sso_client_id=_value(env, "OIDC_CLIENT_ID", ""),
169
185
  sso_client_secret=_value(env, "OIDC_CLIENT_SECRET", ""),