ltcai 3.6.0 → 4.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.
- package/README.md +39 -31
- package/docs/CHANGELOG.md +64 -0
- package/docs/REALTIME_COLLABORATION.md +3 -3
- package/docs/V3_FRONTEND.md +9 -8
- package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +552 -0
- package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
- package/docs/kg-schema.md +51 -53
- package/docs/spec-vs-impl.md +10 -10
- package/kg_schema.py +2 -520
- package/knowledge_graph.py +37 -4629
- package/knowledge_graph_api.py +11 -127
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +16 -17
- package/latticeai/api/agents.py +20 -7
- package/latticeai/api/auth.py +46 -15
- package/latticeai/api/chat.py +112 -76
- package/latticeai/api/health.py +1 -1
- package/latticeai/api/hooks.py +1 -1
- package/latticeai/api/invitations.py +100 -0
- package/latticeai/api/knowledge_graph.py +139 -0
- package/latticeai/api/local_files.py +1 -1
- package/latticeai/api/mcp.py +23 -11
- package/latticeai/api/memory.py +1 -1
- package/latticeai/api/models.py +1 -1
- package/latticeai/api/network.py +81 -0
- package/latticeai/api/plugins.py +3 -6
- package/latticeai/api/realtime.py +5 -8
- package/latticeai/api/search.py +26 -2
- package/latticeai/api/security_dashboard.py +2 -3
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +11 -16
- package/latticeai/api/tools.py +3 -0
- package/latticeai/api/ui_redirects.py +26 -0
- package/latticeai/api/workflow_designer.py +85 -6
- package/latticeai/api/workspace.py +93 -57
- package/latticeai/app_factory.py +1781 -0
- package/latticeai/brain/__init__.py +18 -0
- package/latticeai/brain/_kg_common.py +1123 -0
- package/latticeai/brain/context.py +213 -0
- package/latticeai/brain/conversations.py +236 -0
- package/latticeai/brain/discovery.py +1455 -0
- package/latticeai/brain/documents.py +218 -0
- package/latticeai/brain/identity.py +175 -0
- package/latticeai/brain/ingest.py +644 -0
- package/latticeai/brain/memory.py +102 -0
- package/latticeai/brain/network.py +205 -0
- package/latticeai/brain/projection.py +561 -0
- package/latticeai/brain/provenance.py +401 -0
- package/latticeai/brain/retrieval.py +1316 -0
- package/latticeai/brain/schema.py +640 -0
- package/latticeai/brain/store.py +216 -0
- package/latticeai/brain/write_master.py +225 -0
- package/latticeai/core/agent.py +31 -7
- package/latticeai/core/audit.py +0 -7
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/context_builder.py +1 -2
- package/latticeai/core/enterprise.py +1 -1
- package/latticeai/core/graph_curator.py +2 -2
- package/latticeai/core/invitations.py +131 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +791 -0
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/model_resolution.py +0 -1
- package/latticeai/core/multi_agent.py +238 -4
- package/latticeai/core/policy.py +54 -0
- package/latticeai/core/realtime.py +65 -44
- package/latticeai/core/security.py +1 -1
- package/latticeai/core/sessions.py +66 -10
- package/latticeai/core/users.py +147 -0
- package/latticeai/core/workflow_engine.py +114 -2
- package/latticeai/core/workspace_os.py +477 -29
- package/latticeai/models/__init__.py +7 -0
- package/latticeai/models/router.py +779 -0
- package/latticeai/server_app.py +29 -1536
- package/latticeai/services/agent_runtime.py +243 -4
- package/latticeai/services/app_context.py +75 -14
- package/latticeai/services/ingestion.py +47 -0
- package/latticeai/services/kg_portability.py +33 -3
- package/latticeai/services/memory_service.py +39 -11
- package/latticeai/services/model_runtime.py +2 -5
- package/latticeai/services/platform_runtime.py +100 -23
- package/latticeai/services/run_executor.py +328 -0
- package/latticeai/services/search_service.py +17 -8
- package/latticeai/services/tool_dispatch.py +12 -2
- package/latticeai/services/triggers.py +241 -0
- package/latticeai/services/upload_service.py +37 -12
- package/latticeai/services/workspace_service.py +55 -16
- package/llm_router.py +29 -772
- package/ltcai_cli.py +1 -2
- package/mcp_registry.py +25 -788
- package/p_reinforce.py +124 -14
- package/package.json +10 -20
- package/scripts/bump_version.py +99 -0
- package/scripts/generate_diagrams.py +0 -1
- package/scripts/lint_v3.mjs +105 -18
- package/scripts/validate_release_artifacts.py +0 -1
- package/scripts/wheel_smoke.py +142 -0
- package/server.py +11 -7
- package/setup_wizard.py +1142 -0
- package/static/sw.js +81 -52
- package/static/v3/asset-manifest.json +33 -25
- package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
- package/static/v3/css/lattice.base.css +1 -1
- package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
- package/static/v3/css/lattice.components.css +1 -1
- package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
- package/static/v3/css/lattice.shell.css +1 -1
- package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
- package/static/v3/css/lattice.tokens.css +3 -0
- package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
- package/static/v3/css/lattice.views.css +2 -2
- package/static/v3/index.html +3 -4
- package/static/v3/js/{app.c541f955.js → app.c5c80c46.js} +1 -1
- package/static/v3/js/core/{api.33d6320e.js → api.ba0fbf14.js} +58 -1
- package/static/v3/js/core/api.js +57 -0
- package/static/v3/js/core/i18n.880e1fec.js +575 -0
- package/static/v3/js/core/i18n.js +575 -0
- package/static/v3/js/core/routes.37522821.js +101 -0
- package/static/v3/js/core/routes.js +71 -63
- package/static/v3/js/core/{shell.8c163e0e.js → shell.e3f6bbfa.js} +68 -39
- package/static/v3/js/core/shell.js +66 -37
- package/static/v3/js/core/{store.34ebd5e6.js → store.7b2aa044.js} +11 -1
- package/static/v3/js/core/store.js +11 -1
- package/static/v3/js/views/account.eff40715.js +143 -0
- package/static/v3/js/views/account.js +143 -0
- package/static/v3/js/views/activity.0d271ef9.js +67 -0
- package/static/v3/js/views/activity.js +67 -0
- package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
- package/static/v3/js/views/admin-users.js +4 -6
- package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
- package/static/v3/js/views/agents.js +35 -12
- package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
- package/static/v3/js/views/chat.js +23 -0
- package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
- package/static/v3/js/views/graph-canvas.js +509 -0
- package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
- package/static/v3/js/views/hybrid-search.js +1 -2
- package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.4d09c537.js} +60 -44
- package/static/v3/js/views/knowledge-graph.js +60 -44
- package/static/v3/js/views/network.52a4f181.js +97 -0
- package/static/v3/js/views/network.js +97 -0
- package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
- package/static/v3/js/views/planning.js +26 -5
- package/static/v3/js/views/runs.b63b2afa.js +144 -0
- package/static/v3/js/views/runs.js +144 -0
- package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
- package/static/v3/js/views/settings.js +7 -8
- package/static/v3/js/views/snapshots.6f5db095.js +135 -0
- package/static/v3/js/views/snapshots.js +135 -0
- package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
- package/static/v3/js/views/workflows.js +87 -2
- package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
- package/static/v3/js/views/workspace-admin.js +156 -0
- package/static/vendor/chart.umd.min.js +20 -0
- package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
- package/static/vendor/fonts/inter.css +44 -0
- package/static/vendor/icons/tabler-icons.min.css +4 -0
- package/static/vendor/icons/tabler-icons.woff2 +0 -0
- package/static/vendor/marked.min.js +69 -0
- package/telegram_bot.py +1 -2
- package/tools/commands.py +4 -2
- package/tools/computer.py +1 -1
- package/tools/documents.py +1 -3
- package/tools/filesystem.py +0 -4
- package/tools/knowledge.py +1 -3
- package/tools/network.py +1 -3
- package/codex_telegram_bot.py +0 -195
- package/docs/assets/v3.4.0/agent-run.png +0 -0
- package/docs/assets/v3.4.0/agents.png +0 -0
- package/docs/assets/v3.4.0/before/chat-before.png +0 -0
- package/docs/assets/v3.4.0/before/files-before.png +0 -0
- package/docs/assets/v3.4.0/chat.png +0 -0
- package/docs/assets/v3.4.0/connect-folder.png +0 -0
- package/docs/assets/v3.4.0/files.png +0 -0
- package/docs/assets/v3.4.0/home.png +0 -0
- package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
- package/docs/assets/v3.4.0/local-agent.png +0 -0
- package/docs/assets/v3.4.0/memory.png +0 -0
- package/docs/assets/v3.4.0/settings.png +0 -0
- package/docs/assets/v3.4.0/vision-input.png +0 -0
- package/docs/assets/v3.4.0/workflows.png +0 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/docs/images/admin-dashboard.png +0 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/enterprise.png +0 -0
- package/docs/images/graph.png +0 -0
- package/docs/images/hero.gif +0 -0
- package/docs/images/knowledge-graph.png +0 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/lattice-ai-hero.png +0 -0
- package/docs/images/logo.svg +0 -33
- package/docs/images/mobile-responsive.png +0 -0
- package/docs/images/model-recommendation.png +0 -0
- package/docs/images/onboarding.png +0 -0
- package/docs/images/organization.png +0 -0
- package/docs/images/pipeline.png +0 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -0
- package/docs/images/skills.png +0 -0
- package/docs/images/workspace-dark.png +0 -0
- package/docs/images/workspace-light.png +0 -0
- package/docs/images/workspace.png +0 -0
- package/requirements.txt +0 -16
- package/static/account.html +0 -115
- package/static/activity.html +0 -73
- package/static/admin.html +0 -488
- package/static/agents.html +0 -139
- package/static/chat.html +0 -844
- package/static/css/reference/account.css +0 -439
- package/static/css/reference/admin.css +0 -610
- package/static/css/reference/base.css +0 -1661
- package/static/css/reference/chat.css +0 -4623
- package/static/css/reference/graph.css +0 -1016
- package/static/css/responsive.css +0 -861
- package/static/graph.html +0 -124
- package/static/platform.css +0 -104
- package/static/plugins.html +0 -136
- package/static/scripts/account.js +0 -238
- package/static/scripts/admin.js +0 -1614
- package/static/scripts/chat.js +0 -5081
- package/static/scripts/graph.js +0 -1804
- package/static/scripts/platform.js +0 -64
- package/static/scripts/ux.js +0 -167
- package/static/scripts/workspace.js +0 -948
- package/static/v3/js/core/routes.2ce3815a.js +0 -93
- package/static/workflows.html +0 -146
- package/static/workspace.css +0 -1121
- package/static/workspace.html +0 -357
package/knowledge_graph_api.py
CHANGED
|
@@ -1,130 +1,14 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Deprecation shim — the knowledge graph router moved in v4.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
The ``/knowledge-graph/*`` data router (and legacy ``/graph`` page routes)
|
|
4
|
+
now live in :mod:`latticeai.api.knowledge_graph`. This root module remains
|
|
5
|
+
importable for the deprecation window and will be removed in a future major
|
|
6
|
+
release.
|
|
7
|
+
"""
|
|
5
8
|
|
|
6
|
-
from
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
from latticeai.api.knowledge_graph import ( # noqa: F401
|
|
10
|
+
KnowledgeGraphIngestRequest,
|
|
11
|
+
create_knowledge_graph_router,
|
|
12
|
+
)
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
class KnowledgeGraphIngestRequest(BaseModel):
|
|
12
|
-
type: str
|
|
13
|
-
content: str = ""
|
|
14
|
-
role: Optional[str] = None
|
|
15
|
-
title: Optional[str] = None
|
|
16
|
-
source: Optional[str] = None
|
|
17
|
-
conversation_id: Optional[str] = None
|
|
18
|
-
user_email: Optional[str] = None
|
|
19
|
-
user_nickname: Optional[str] = None
|
|
20
|
-
metadata: Optional[Dict[str, Any]] = None
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def create_knowledge_graph_router(
|
|
24
|
-
*,
|
|
25
|
-
get_graph: Callable[[], Any],
|
|
26
|
-
require_graph: Callable[[], None],
|
|
27
|
-
require_user: Callable[[Request], str],
|
|
28
|
-
static_dir: Path,
|
|
29
|
-
) -> APIRouter:
|
|
30
|
-
router = APIRouter()
|
|
31
|
-
|
|
32
|
-
def graph():
|
|
33
|
-
require_graph()
|
|
34
|
-
return get_graph()
|
|
35
|
-
|
|
36
|
-
@router.get("/graph")
|
|
37
|
-
async def knowledge_graph_page(request: Request):
|
|
38
|
-
"""Serve the interactive knowledge graph canvas UI."""
|
|
39
|
-
graph()
|
|
40
|
-
require_user(request)
|
|
41
|
-
response = FileResponse(static_dir / "graph.html")
|
|
42
|
-
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
43
|
-
response.headers["Pragma"] = "no-cache"
|
|
44
|
-
response.headers["Expires"] = "0"
|
|
45
|
-
return response
|
|
46
|
-
|
|
47
|
-
@router.get("/knowledge-graph")
|
|
48
|
-
async def knowledge_graph_legacy_page(request: Request):
|
|
49
|
-
"""Backward-compatible route for the graph page."""
|
|
50
|
-
graph()
|
|
51
|
-
require_user(request)
|
|
52
|
-
response = FileResponse(static_dir / "graph.html")
|
|
53
|
-
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
54
|
-
response.headers["Pragma"] = "no-cache"
|
|
55
|
-
response.headers["Expires"] = "0"
|
|
56
|
-
return response
|
|
57
|
-
|
|
58
|
-
@router.get("/knowledge-graph/stats")
|
|
59
|
-
async def knowledge_graph_stats(request: Request):
|
|
60
|
-
require_user(request)
|
|
61
|
-
return graph().stats()
|
|
62
|
-
|
|
63
|
-
@router.get("/knowledge-graph/schema")
|
|
64
|
-
async def knowledge_graph_schema(request: Request):
|
|
65
|
-
require_user(request)
|
|
66
|
-
stats = graph().stats()
|
|
67
|
-
return {
|
|
68
|
-
"legacy_schema_version": stats.get("schema_version"),
|
|
69
|
-
"v2_schema_available": stats.get("v2_schema_available"),
|
|
70
|
-
"v2": stats.get("v2"),
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
@router.get("/knowledge-graph/graph")
|
|
74
|
-
async def knowledge_graph_data(request: Request, limit: int = 300):
|
|
75
|
-
require_user(request)
|
|
76
|
-
return graph().graph(limit)
|
|
77
|
-
|
|
78
|
-
@router.get("/knowledge-graph/documents")
|
|
79
|
-
async def knowledge_graph_documents(request: Request, limit: int = 200):
|
|
80
|
-
"""Ingested documents (uploads + indexed local docs) with index state.
|
|
81
|
-
|
|
82
|
-
Backs the Files view so uploaded content is visible end-to-end:
|
|
83
|
-
upload → Files → Knowledge Graph → Hybrid Search → Chat.
|
|
84
|
-
"""
|
|
85
|
-
require_user(request)
|
|
86
|
-
return graph().list_documents(limit)
|
|
87
|
-
|
|
88
|
-
@router.get("/knowledge-graph/search")
|
|
89
|
-
async def knowledge_graph_search(q: str, request: Request, limit: int = 30):
|
|
90
|
-
require_user(request)
|
|
91
|
-
if not q or not q.strip():
|
|
92
|
-
return {"query": q, "matches": []}
|
|
93
|
-
return graph().search(q, limit)
|
|
94
|
-
|
|
95
|
-
@router.get("/knowledge-graph/context")
|
|
96
|
-
async def knowledge_graph_context(q: str, request: Request, limit: int = 6):
|
|
97
|
-
require_user(request)
|
|
98
|
-
return {"query": q, "context": graph().context_for_query(q, limit)}
|
|
99
|
-
|
|
100
|
-
@router.get("/knowledge-graph/neighbors/{node_id:path}")
|
|
101
|
-
async def knowledge_graph_neighbors(node_id: str, request: Request):
|
|
102
|
-
require_user(request)
|
|
103
|
-
if not node_id:
|
|
104
|
-
raise HTTPException(status_code=400, detail="node_id required")
|
|
105
|
-
return graph().neighbors(node_id)
|
|
106
|
-
|
|
107
|
-
@router.post("/knowledge-graph/ingest")
|
|
108
|
-
async def knowledge_graph_ingest(req: KnowledgeGraphIngestRequest, request: Request):
|
|
109
|
-
current_user = require_user(request)
|
|
110
|
-
kg = graph()
|
|
111
|
-
event_type = (req.type or "").strip().lower()
|
|
112
|
-
if event_type not in {"message", "ai_response", "note"}:
|
|
113
|
-
raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
|
|
114
|
-
role = req.role or ("assistant" if event_type == "ai_response" else "user")
|
|
115
|
-
return kg.ingest_message(
|
|
116
|
-
role,
|
|
117
|
-
req.content,
|
|
118
|
-
user_email=req.user_email or current_user,
|
|
119
|
-
user_nickname=req.user_nickname,
|
|
120
|
-
source=req.source or "mcp",
|
|
121
|
-
conversation_id=req.conversation_id,
|
|
122
|
-
raw={
|
|
123
|
-
"type": req.type,
|
|
124
|
-
"title": req.title,
|
|
125
|
-
"content": req.content,
|
|
126
|
-
"metadata": req.metadata or {},
|
|
127
|
-
},
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
return router
|
|
14
|
+
__all__ = ["KnowledgeGraphIngestRequest", "create_knowledge_graph_router"]
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/admin.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
from collections import defaultdict
|
|
5
|
-
from typing import
|
|
5
|
+
from typing import Callable, Dict, List, Optional
|
|
6
6
|
|
|
7
7
|
from fastapi import APIRouter, HTTPException, Request
|
|
8
8
|
from pydantic import BaseModel
|
|
@@ -55,6 +55,7 @@ def create_admin_router(
|
|
|
55
55
|
invite_code: str,
|
|
56
56
|
invite_gate_enabled: bool,
|
|
57
57
|
default_port: int,
|
|
58
|
+
policy_matrix: Optional[Callable[[], List[Dict[str, object]]]] = None,
|
|
58
59
|
) -> APIRouter:
|
|
59
60
|
router = APIRouter()
|
|
60
61
|
|
|
@@ -109,16 +110,6 @@ def create_admin_router(
|
|
|
109
110
|
report["graph"] = {"error": str(e)}
|
|
110
111
|
return report
|
|
111
112
|
|
|
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
113
|
@router.get("/admin/roles")
|
|
123
114
|
async def admin_roles(request: Request):
|
|
124
115
|
_, users = require_admin(request)
|
|
@@ -126,13 +117,21 @@ def create_admin_router(
|
|
|
126
117
|
for email, user in users.items():
|
|
127
118
|
role = (get_user_role(email, users) or "user").lower()
|
|
128
119
|
counts[role] += 1
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
120
|
+
matrix = policy_matrix() if policy_matrix else [
|
|
121
|
+
{"role": "admin", "caps": ["all"]},
|
|
122
|
+
{"role": "user", "caps": ["chat", "search"]},
|
|
123
|
+
]
|
|
124
|
+
policy_caps = {
|
|
125
|
+
str(item.get("role") or "user"): list(item.get("caps") or [])
|
|
126
|
+
for item in matrix
|
|
127
|
+
if isinstance(item, dict)
|
|
128
|
+
}
|
|
129
|
+
for role in policy_caps:
|
|
130
|
+
counts.setdefault(role, 0)
|
|
131
|
+
order = {"owner": 0, "admin": 1, "member": 2, "user": 3, "viewer": 4}
|
|
133
132
|
roles = [
|
|
134
|
-
{"role": role, "members": counts.get(role, 0), "caps":
|
|
135
|
-
for role in sorted(counts, key=lambda r: (r
|
|
133
|
+
{"role": role, "members": counts.get(role, 0), "caps": policy_caps.get(role, [])}
|
|
134
|
+
for role in sorted(counts, key=lambda r: (order.get(r, 99), r))
|
|
136
135
|
]
|
|
137
136
|
return {"roles": roles}
|
|
138
137
|
|
package/latticeai/api/agents.py
CHANGED
|
@@ -14,6 +14,8 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
14
14
|
from fastapi import APIRouter, HTTPException, Request
|
|
15
15
|
from pydantic import BaseModel
|
|
16
16
|
|
|
17
|
+
from latticeai.api.ui_redirects import app_redirect
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
class AgentRunRequest(BaseModel):
|
|
19
21
|
goal: str
|
|
@@ -41,6 +43,7 @@ def create_agents_router(
|
|
|
41
43
|
ui_file_response: Optional[Callable[[Path], Any]] = None,
|
|
42
44
|
static_dir: Optional[Path] = None,
|
|
43
45
|
agent_runtime: Any = None,
|
|
46
|
+
run_executor: Any = None,
|
|
44
47
|
) -> APIRouter:
|
|
45
48
|
from latticeai.core.multi_agent import AGENT_ROLES, ROLE_AGENT_IDS
|
|
46
49
|
from latticeai.services.agent_runtime import AgentRuntime
|
|
@@ -91,12 +94,7 @@ def create_agents_router(
|
|
|
91
94
|
@router.get("/agents")
|
|
92
95
|
async def agents_page(request: Request):
|
|
93
96
|
require_user(request)
|
|
94
|
-
|
|
95
|
-
raise HTTPException(status_code=404, detail="Multi-Agent UI not available.")
|
|
96
|
-
page = static_dir / "agents.html"
|
|
97
|
-
if not page.exists():
|
|
98
|
-
raise HTTPException(status_code=404, detail="Multi-Agent UI not found.")
|
|
99
|
-
return ui_file_response(page)
|
|
97
|
+
return app_redirect("agents", request)
|
|
100
98
|
|
|
101
99
|
@router.get("/agents/api/roles")
|
|
102
100
|
async def agent_roles(request: Request):
|
|
@@ -163,7 +161,22 @@ def create_agents_router(
|
|
|
163
161
|
current_user = require_user(request)
|
|
164
162
|
scope = gate_write(request)
|
|
165
163
|
try:
|
|
166
|
-
|
|
164
|
+
if run_executor is not None:
|
|
165
|
+
return await run_executor.start_agent(
|
|
166
|
+
req.goal,
|
|
167
|
+
user_email=current_user or None,
|
|
168
|
+
scope=scope,
|
|
169
|
+
roles=req.roles or None,
|
|
170
|
+
inputs=req.inputs,
|
|
171
|
+
max_retries=req.max_retries,
|
|
172
|
+
)
|
|
173
|
+
# Worker thread: an LLM-backed run blocks on model generation and
|
|
174
|
+
# must not stall the event loop (the sync model bridge also
|
|
175
|
+
# requires a loop-free thread).
|
|
176
|
+
import asyncio
|
|
177
|
+
|
|
178
|
+
return await asyncio.to_thread(
|
|
179
|
+
runtime.start,
|
|
167
180
|
req.goal,
|
|
168
181
|
user_email=current_user or None,
|
|
169
182
|
scope=scope,
|
package/latticeai/api/auth.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Authentication API router: register, login, logout, SSO, profile."""
|
|
2
2
|
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
3
5
|
import logging
|
|
4
6
|
import secrets
|
|
5
7
|
import time
|
|
@@ -10,6 +12,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
10
12
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
11
13
|
from pydantic import BaseModel
|
|
12
14
|
|
|
15
|
+
from latticeai.core.users import normalize_email
|
|
13
16
|
from latticeai.core.oidc import (
|
|
14
17
|
OIDCValidationError,
|
|
15
18
|
fetch_jwks as _default_fetch_jwks,
|
|
@@ -64,27 +67,42 @@ def create_auth_router(
|
|
|
64
67
|
open_registration: bool,
|
|
65
68
|
session_ttl: int,
|
|
66
69
|
require_auth: bool = True,
|
|
70
|
+
ensure_identity: Optional[Callable[[str, Dict], None]] = None,
|
|
67
71
|
verify_id_token: Callable[..., Dict] = _default_verify_id_token,
|
|
68
72
|
fetch_jwks: Callable[[str], Awaitable[Dict]] = _default_fetch_jwks,
|
|
69
73
|
) -> APIRouter:
|
|
70
74
|
router = APIRouter()
|
|
71
75
|
|
|
76
|
+
def _enforce_password_policy(password: str) -> None:
|
|
77
|
+
# Real policy (v4): length >= 8 with letters AND digits. A 4-char
|
|
78
|
+
# minimum was not a policy.
|
|
79
|
+
pw = str(password or "")
|
|
80
|
+
if len(pw) < 8 or not any(c.isalpha() for c in pw) or not any(c.isdigit() for c in pw):
|
|
81
|
+
raise HTTPException(
|
|
82
|
+
status_code=400,
|
|
83
|
+
detail="비밀번호는 8자 이상이며 영문자와 숫자를 모두 포함해야 합니다.",
|
|
84
|
+
)
|
|
85
|
+
|
|
72
86
|
@router.post("/register")
|
|
73
87
|
async def register(req: UserRegister, request: Request):
|
|
74
88
|
check_ip_rate_limit(client_ip(request), "register", max_calls=5, window_secs=3600)
|
|
75
89
|
if not open_registration:
|
|
76
90
|
raise HTTPException(status_code=403, detail="회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.")
|
|
91
|
+
_enforce_password_policy(req.password)
|
|
92
|
+
email = normalize_email(req.email)
|
|
77
93
|
users = load_users()
|
|
78
|
-
if
|
|
94
|
+
if email in users:
|
|
79
95
|
raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다.")
|
|
80
96
|
role = "admin" if not users else "user"
|
|
81
|
-
users[
|
|
97
|
+
users[email] = {
|
|
82
98
|
"password": hash_password(req.password),
|
|
83
99
|
"name": req.name,
|
|
84
100
|
"nickname": req.nickname,
|
|
85
101
|
"role": role,
|
|
86
102
|
"disabled": False,
|
|
87
103
|
}
|
|
104
|
+
if ensure_identity is not None:
|
|
105
|
+
ensure_identity(email, users[email])
|
|
88
106
|
save_users(users)
|
|
89
107
|
msg = "회원가입 성공! 첫 번째 사용자로 관리자 권한이 부여되었습니다." if role == "admin" else "회원가입 성공!"
|
|
90
108
|
return {"status": "ok", "message": msg, "role": role}
|
|
@@ -92,19 +110,20 @@ def create_auth_router(
|
|
|
92
110
|
@router.post("/login")
|
|
93
111
|
async def login(req: UserLogin, request: Request):
|
|
94
112
|
check_ip_rate_limit(client_ip(request), "login", max_calls=10, window_secs=300)
|
|
113
|
+
email = normalize_email(req.email)
|
|
95
114
|
users = load_users()
|
|
96
|
-
user = users.get(
|
|
97
|
-
if not user or not verify_and_migrate(
|
|
115
|
+
user = users.get(email)
|
|
116
|
+
if not user or not verify_and_migrate(email, req.password, user.get("password", ""), users):
|
|
98
117
|
raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 틀렸습니다.")
|
|
99
118
|
if user.get("disabled"):
|
|
100
119
|
raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
|
|
101
|
-
role = get_user_role(
|
|
102
|
-
token = create_session(
|
|
120
|
+
role = get_user_role(email, users)
|
|
121
|
+
token = create_session(email)
|
|
103
122
|
response = JSONResponse(content={
|
|
104
123
|
"status": "ok",
|
|
105
124
|
"nickname": user["nickname"],
|
|
106
125
|
"name": user["name"],
|
|
107
|
-
"email":
|
|
126
|
+
"email": email,
|
|
108
127
|
"role": role,
|
|
109
128
|
"is_admin": role == "admin",
|
|
110
129
|
})
|
|
@@ -123,7 +142,15 @@ def create_auth_router(
|
|
|
123
142
|
raise HTTPException(status_code=503, detail="SSO가 설정되지 않았습니다.")
|
|
124
143
|
state = secrets.token_urlsafe(16)
|
|
125
144
|
nonce = secrets.token_urlsafe(16)
|
|
126
|
-
|
|
145
|
+
# PKCE (S256): bind the token exchange to this login, so an
|
|
146
|
+
# intercepted authorization code is useless without the verifier.
|
|
147
|
+
code_verifier = secrets.token_urlsafe(48)
|
|
148
|
+
code_challenge = (
|
|
149
|
+
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest())
|
|
150
|
+
.rstrip(b"=")
|
|
151
|
+
.decode("ascii")
|
|
152
|
+
)
|
|
153
|
+
_sso_states[state] = (time.time(), nonce, code_verifier)
|
|
127
154
|
params = urlencode({
|
|
128
155
|
"client_id": settings["client_id"],
|
|
129
156
|
"response_type": "code",
|
|
@@ -131,6 +158,8 @@ def create_auth_router(
|
|
|
131
158
|
"scope": settings.get("scopes") or "openid email profile",
|
|
132
159
|
"state": state,
|
|
133
160
|
"nonce": nonce,
|
|
161
|
+
"code_challenge": code_challenge,
|
|
162
|
+
"code_challenge_method": "S256",
|
|
134
163
|
})
|
|
135
164
|
return RedirectResponse(f"{discovery['authorization_endpoint']}?{params}")
|
|
136
165
|
|
|
@@ -141,7 +170,7 @@ def create_auth_router(
|
|
|
141
170
|
entry = _sso_states.pop(state, None)
|
|
142
171
|
if entry is None or time.time() - entry[0] > 300:
|
|
143
172
|
raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
|
|
144
|
-
_, nonce = entry
|
|
173
|
+
_, nonce, code_verifier = entry
|
|
145
174
|
settings = get_sso_settings()
|
|
146
175
|
discovery = await get_sso_discovery()
|
|
147
176
|
if not settings.get("enabled") or not discovery:
|
|
@@ -154,6 +183,7 @@ def create_auth_router(
|
|
|
154
183
|
"redirect_uri": settings["redirect_uri"],
|
|
155
184
|
"client_id": settings["client_id"],
|
|
156
185
|
"client_secret": settings["client_secret"],
|
|
186
|
+
"code_verifier": code_verifier,
|
|
157
187
|
}, headers={"Accept": "application/json"}, timeout=15)
|
|
158
188
|
tokens = r.json()
|
|
159
189
|
id_token = tokens.get("id_token")
|
|
@@ -178,7 +208,7 @@ def create_auth_router(
|
|
|
178
208
|
except Exception as exc: # discovery/JWKS fetch failure → fail closed
|
|
179
209
|
logging.warning("SSO token validation error: %s", exc)
|
|
180
210
|
raise HTTPException(status_code=502, detail="SSO 공급자 검증에 실패했습니다.")
|
|
181
|
-
email = payload.get("email") or payload.get("preferred_username") or payload.get("upn") or ""
|
|
211
|
+
email = normalize_email(payload.get("email") or payload.get("preferred_username") or payload.get("upn") or "")
|
|
182
212
|
if not email:
|
|
183
213
|
raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
|
|
184
214
|
users = load_users()
|
|
@@ -192,6 +222,8 @@ def create_auth_router(
|
|
|
192
222
|
"disabled": False,
|
|
193
223
|
"sso": True,
|
|
194
224
|
}
|
|
225
|
+
if ensure_identity is not None:
|
|
226
|
+
ensure_identity(email, users[email])
|
|
195
227
|
save_users(users)
|
|
196
228
|
if users[email].get("disabled"):
|
|
197
229
|
raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
|
|
@@ -211,11 +243,10 @@ def create_auth_router(
|
|
|
211
243
|
|
|
212
244
|
@router.post("/account/change-password")
|
|
213
245
|
async def change_password(req: ChangePasswordRequest, request: Request):
|
|
214
|
-
email = require_user(request)
|
|
246
|
+
email = normalize_email(require_user(request))
|
|
215
247
|
if not email:
|
|
216
248
|
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|
|
217
|
-
|
|
218
|
-
raise HTTPException(status_code=400, detail="새 비밀번호는 4자 이상이어야 합니다.")
|
|
249
|
+
_enforce_password_policy(req.new_password)
|
|
219
250
|
users = load_users()
|
|
220
251
|
user = users.get(email)
|
|
221
252
|
if not user:
|
|
@@ -228,7 +259,7 @@ def create_auth_router(
|
|
|
228
259
|
|
|
229
260
|
@router.patch("/account/profile")
|
|
230
261
|
async def update_profile(req: UpdateProfileRequest, request: Request):
|
|
231
|
-
email = require_user(request)
|
|
262
|
+
email = normalize_email(require_user(request))
|
|
232
263
|
if not email:
|
|
233
264
|
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|
|
234
265
|
if req.name is not None and not req.name.strip():
|
|
@@ -248,7 +279,7 @@ def create_auth_router(
|
|
|
248
279
|
|
|
249
280
|
@router.get("/account/profile")
|
|
250
281
|
async def get_profile(request: Request):
|
|
251
|
-
email = require_user(request)
|
|
282
|
+
email = normalize_email(require_user(request))
|
|
252
283
|
if not email:
|
|
253
284
|
if require_auth:
|
|
254
285
|
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|