ltcai 6.3.0 → 6.4.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 -36
- package/docs/CHANGELOG.md +55 -0
- package/frontend/src/features/review/ReviewCard.tsx +12 -10
- package/frontend/src/features/review/ReviewInbox.tsx +15 -10
- package/frontend/src/i18n.ts +116 -0
- package/frontend/src/pages/Capture.tsx +48 -41
- package/lattice_brain/__init__.py +3 -1
- package/lattice_brain/quality.py +409 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/knowledge_graph.py +71 -10
- package/latticeai/api/local_files.py +2 -0
- package/latticeai/api/memory.py +6 -6
- package/latticeai/api/search.py +9 -1
- package/latticeai/api/tools.py +3 -0
- package/latticeai/app_factory.py +17 -67
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/access_runtime.py +97 -0
- package/latticeai/runtime/chat_wiring.py +1 -0
- package/latticeai/services/memory_service.py +64 -21
- package/latticeai/services/router_context.py +4 -0
- package/latticeai/services/search_service.py +61 -10
- package/package.json +1 -1
- 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 +3 -3
- package/static/app/assets/{index-D76dWuQk.js → index-t1jx1BR9.js} +2 -2
- package/static/app/assets/index-t1jx1BR9.js.map +1 -0
- package/static/app/index.html +1 -1
- package/static/app/assets/index-D76dWuQk.js.map +0 -1
|
@@ -27,12 +27,32 @@ class KnowledgeGraphIngestRequest(BaseModel):
|
|
|
27
27
|
metadata: Optional[Dict[str, Any]] = None
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def _format_context(matches: list, limit: int) -> str:
|
|
31
|
+
"""Mirror ``KnowledgeGraphRetrievalMixin.context_for_query`` formatting for a
|
|
32
|
+
pre-filtered match list, so scoped callers get identical context lines minus
|
|
33
|
+
the rows they are not allowed to see."""
|
|
34
|
+
lines = []
|
|
35
|
+
for match in matches[:limit]:
|
|
36
|
+
meta = match.get("metadata") or {}
|
|
37
|
+
source = (
|
|
38
|
+
meta.get("relative_path")
|
|
39
|
+
or meta.get("filename")
|
|
40
|
+
or meta.get("conversation_id")
|
|
41
|
+
or meta.get("source")
|
|
42
|
+
or match.get("id")
|
|
43
|
+
)
|
|
44
|
+
summary = " ".join(str(match.get("summary") or "").split())[:700]
|
|
45
|
+
lines.append(f"- [{match.get('type')}] {match.get('title')} | source={source} | {summary}")
|
|
46
|
+
return "\n".join(lines)
|
|
47
|
+
|
|
48
|
+
|
|
30
49
|
def create_knowledge_graph_router(
|
|
31
50
|
*,
|
|
32
51
|
get_graph: Callable[[], Any],
|
|
33
52
|
require_graph: Callable[[], None],
|
|
34
53
|
require_user: Callable[[Request], str],
|
|
35
54
|
static_dir: Path,
|
|
55
|
+
allowed_workspaces_for: Optional[Callable[[Optional[str]], Any]] = None,
|
|
36
56
|
) -> APIRouter:
|
|
37
57
|
router = APIRouter()
|
|
38
58
|
|
|
@@ -40,6 +60,21 @@ def create_knowledge_graph_router(
|
|
|
40
60
|
require_graph()
|
|
41
61
|
return get_graph()
|
|
42
62
|
|
|
63
|
+
def _scoped(request: Request):
|
|
64
|
+
"""Authenticate the caller and resolve their allowed workspace set.
|
|
65
|
+
|
|
66
|
+
Returns ``(graph, allowed)``. ``allowed is None`` means no scoping
|
|
67
|
+
(single-user / no-auth mode); otherwise it is the set of workspace ids
|
|
68
|
+
the caller may read. Legacy-global rows (no workspace) stay visible —
|
|
69
|
+
the documented pre-v4 compatibility behavior enforced by
|
|
70
|
+
``filter_scoped_nodes``.
|
|
71
|
+
"""
|
|
72
|
+
user = require_user(request)
|
|
73
|
+
allowed = None
|
|
74
|
+
if allowed_workspaces_for is not None and user:
|
|
75
|
+
allowed = allowed_workspaces_for(user)
|
|
76
|
+
return graph(), allowed
|
|
77
|
+
|
|
43
78
|
@router.get("/graph")
|
|
44
79
|
async def knowledge_graph_page(request: Request):
|
|
45
80
|
"""Serve the interactive knowledge graph canvas UI."""
|
|
@@ -81,8 +116,10 @@ def create_knowledge_graph_router(
|
|
|
81
116
|
|
|
82
117
|
@router.get("/knowledge-graph/graph")
|
|
83
118
|
async def knowledge_graph_data(request: Request, limit: int = 300):
|
|
84
|
-
|
|
85
|
-
|
|
119
|
+
kg, allowed = _scoped(request)
|
|
120
|
+
if allowed is None:
|
|
121
|
+
return kg.graph(limit)
|
|
122
|
+
return kg.graph(limit, allowed_workspaces=allowed)
|
|
86
123
|
|
|
87
124
|
@router.get("/knowledge-graph/documents")
|
|
88
125
|
async def knowledge_graph_documents(request: Request, limit: int = 200):
|
|
@@ -91,27 +128,51 @@ def create_knowledge_graph_router(
|
|
|
91
128
|
Backs the Files view so uploaded content is visible end-to-end:
|
|
92
129
|
upload → Files → Knowledge Graph → Hybrid Search → Chat.
|
|
93
130
|
"""
|
|
94
|
-
|
|
95
|
-
|
|
131
|
+
kg, allowed = _scoped(request)
|
|
132
|
+
payload = kg.list_documents(limit)
|
|
133
|
+
if allowed is not None:
|
|
134
|
+
documents = kg.filter_scoped_nodes(payload.get("documents", []), allowed)
|
|
135
|
+
payload = {**payload, "documents": documents, "total": len(documents)}
|
|
136
|
+
return payload
|
|
96
137
|
|
|
97
138
|
@router.get("/knowledge-graph/search")
|
|
98
139
|
async def knowledge_graph_search(q: str, request: Request, limit: int = 30):
|
|
99
|
-
|
|
140
|
+
kg, allowed = _scoped(request)
|
|
100
141
|
if not q or not q.strip():
|
|
101
142
|
return {"query": q, "matches": []}
|
|
102
|
-
|
|
143
|
+
payload = kg.search(q, limit)
|
|
144
|
+
if allowed is not None:
|
|
145
|
+
payload = {**payload, "matches": kg.filter_scoped_nodes(payload.get("matches", []), allowed)}
|
|
146
|
+
return payload
|
|
103
147
|
|
|
104
148
|
@router.get("/knowledge-graph/context")
|
|
105
149
|
async def knowledge_graph_context(q: str, request: Request, limit: int = 6):
|
|
106
|
-
|
|
107
|
-
|
|
150
|
+
kg, allowed = _scoped(request)
|
|
151
|
+
if allowed is None:
|
|
152
|
+
return {"query": q, "context": kg.context_for_query(q, limit)}
|
|
153
|
+
# Scoped mode: derive context from scope-filtered search matches so the
|
|
154
|
+
# RAG context never carries content from workspaces the caller can't read.
|
|
155
|
+
matches = kg.filter_scoped_nodes(kg.search(q, limit).get("matches", []), allowed)
|
|
156
|
+
return {"query": q, "context": _format_context(matches, limit)}
|
|
108
157
|
|
|
109
158
|
@router.get("/knowledge-graph/neighbors/{node_id:path}")
|
|
110
159
|
async def knowledge_graph_neighbors(node_id: str, request: Request):
|
|
111
|
-
|
|
160
|
+
kg, allowed = _scoped(request)
|
|
112
161
|
if not node_id:
|
|
113
162
|
raise HTTPException(status_code=400, detail="node_id required")
|
|
114
|
-
|
|
163
|
+
if allowed is not None and not kg.filter_scoped_nodes([{"id": node_id}], allowed):
|
|
164
|
+
raise HTTPException(status_code=404, detail="node not found")
|
|
165
|
+
payload = kg.neighbors(node_id)
|
|
166
|
+
if allowed is not None:
|
|
167
|
+
neighbors = kg.filter_scoped_nodes(payload.get("neighbors", []), allowed)
|
|
168
|
+
kept = {n.get("id") for n in neighbors}
|
|
169
|
+
edges = [
|
|
170
|
+
e for e in payload.get("edges", [])
|
|
171
|
+
if (e.get("from") == node_id or e.get("from") in kept)
|
|
172
|
+
and (e.get("to") == node_id or e.get("to") in kept)
|
|
173
|
+
]
|
|
174
|
+
payload = {**payload, "neighbors": neighbors, "edges": edges}
|
|
175
|
+
return payload
|
|
115
176
|
|
|
116
177
|
@router.post("/knowledge-graph/ingest")
|
|
117
178
|
async def knowledge_graph_ingest(req: KnowledgeGraphIngestRequest, request: Request):
|
|
@@ -48,6 +48,7 @@ def create_local_files_router(
|
|
|
48
48
|
local_kg_watcher,
|
|
49
49
|
hooks=None,
|
|
50
50
|
data_dir: Optional[Path] = None,
|
|
51
|
+
allowed_workspaces_for=None,
|
|
51
52
|
) -> APIRouter:
|
|
52
53
|
router = APIRouter()
|
|
53
54
|
|
|
@@ -210,6 +211,7 @@ def create_local_files_router(
|
|
|
210
211
|
require_graph=require_graph,
|
|
211
212
|
require_user=require_user,
|
|
212
213
|
static_dir=static_dir,
|
|
214
|
+
allowed_workspaces_for=allowed_workspaces_for,
|
|
213
215
|
)
|
|
214
216
|
)
|
|
215
217
|
|
package/latticeai/api/memory.py
CHANGED
|
@@ -74,16 +74,16 @@ def create_memory_router(
|
|
|
74
74
|
@router.post("/api/memory/prune")
|
|
75
75
|
async def memory_prune(req: PruneRequest, request: Request):
|
|
76
76
|
user = require_user(request)
|
|
77
|
-
gate_write(request)
|
|
78
|
-
result = service.prune(ids=req.ids, kind=req.kind, user_email=user)
|
|
77
|
+
scope = gate_write(request)
|
|
78
|
+
result = service.prune(ids=req.ids, kind=req.kind, user_email=user, workspace_id=scope)
|
|
79
79
|
append_audit_event("memory_prune", user_email=user, count=result.get("count", 0))
|
|
80
80
|
return result
|
|
81
81
|
|
|
82
82
|
@router.post("/api/memory/compact")
|
|
83
83
|
async def memory_compact(request: Request):
|
|
84
84
|
user = require_user(request)
|
|
85
|
-
gate_write(request)
|
|
86
|
-
result = service.compact(user_email=user)
|
|
85
|
+
scope = gate_write(request)
|
|
86
|
+
result = service.compact(user_email=user, workspace_id=scope)
|
|
87
87
|
append_audit_event("memory_compact", user_email=user, compacted=result.get("compacted", 0))
|
|
88
88
|
return result
|
|
89
89
|
|
|
@@ -98,9 +98,9 @@ def create_memory_router(
|
|
|
98
98
|
@router.post("/api/memory/clear")
|
|
99
99
|
async def memory_clear(req: ClearRequest, request: Request):
|
|
100
100
|
user = require_user(request)
|
|
101
|
-
gate_write(request)
|
|
101
|
+
scope = gate_write(request)
|
|
102
102
|
try:
|
|
103
|
-
result = service.clear(scope=req.scope, confirm=req.confirm, user_email=user)
|
|
103
|
+
result = service.clear(scope=req.scope, confirm=req.confirm, user_email=user, workspace_id=scope)
|
|
104
104
|
except ValueError as exc:
|
|
105
105
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
106
106
|
append_audit_event("memory_clear", user_email=user, scope=req.scope)
|
package/latticeai/api/search.py
CHANGED
|
@@ -51,7 +51,15 @@ class _ScopedSearchService:
|
|
|
51
51
|
"""Injects the caller's workspace scope into every search call —
|
|
52
52
|
enforcement lives at this one chokepoint, not in each handler."""
|
|
53
53
|
|
|
54
|
-
_SCOPED = {
|
|
54
|
+
_SCOPED = {
|
|
55
|
+
"keyword_search",
|
|
56
|
+
"vector_search",
|
|
57
|
+
"graph_search",
|
|
58
|
+
"hybrid_search",
|
|
59
|
+
"graph",
|
|
60
|
+
"node",
|
|
61
|
+
"relationships",
|
|
62
|
+
}
|
|
55
63
|
|
|
56
64
|
def __init__(self, service: SearchService, allowed):
|
|
57
65
|
self._service = service
|
package/latticeai/api/tools.py
CHANGED
|
@@ -202,6 +202,7 @@ def create_tools_router(
|
|
|
202
202
|
install_mcp=None,
|
|
203
203
|
mcp_public_item=None,
|
|
204
204
|
hooks=None,
|
|
205
|
+
allowed_workspaces_for=None,
|
|
205
206
|
) -> APIRouter:
|
|
206
207
|
if tool_context is not None:
|
|
207
208
|
config = tool_context.config
|
|
@@ -227,6 +228,7 @@ def create_tools_router(
|
|
|
227
228
|
install_mcp = tool_context.install_mcp
|
|
228
229
|
mcp_public_item = tool_context.mcp_public_item
|
|
229
230
|
hooks = tool_context.hooks
|
|
231
|
+
allowed_workspaces_for = tool_context.allowed_workspaces_for
|
|
230
232
|
|
|
231
233
|
api_router = APIRouter()
|
|
232
234
|
HOOKS = hooks
|
|
@@ -486,6 +488,7 @@ def create_tools_router(
|
|
|
486
488
|
local_kg_watcher=LOCAL_KG_WATCHER,
|
|
487
489
|
hooks=HOOKS,
|
|
488
490
|
data_dir=DATA_DIR,
|
|
491
|
+
allowed_workspaces_for=allowed_workspaces_for,
|
|
489
492
|
))
|
|
490
493
|
api_router.include_router(create_computer_use_router(
|
|
491
494
|
model_router=router,
|
package/latticeai/app_factory.py
CHANGED
|
@@ -18,6 +18,7 @@ import threading
|
|
|
18
18
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
19
19
|
|
|
20
20
|
from latticeai.runtime.app_context_runtime import build_app_context
|
|
21
|
+
from latticeai.runtime.access_runtime import build_access_runtime
|
|
21
22
|
from latticeai.runtime.bootstrap import build_session_runtime
|
|
22
23
|
from latticeai.runtime.brain_runtime import build_brain_runtime
|
|
23
24
|
from latticeai.runtime.chat_wiring import (
|
|
@@ -122,12 +123,11 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
122
123
|
from latticeai.core.enterprise import (
|
|
123
124
|
capability_registry,
|
|
124
125
|
)
|
|
125
|
-
from latticeai.core.policy import
|
|
126
|
+
from latticeai.core.policy import policy_matrix
|
|
126
127
|
from latticeai.core.users import (
|
|
127
128
|
ensure_user_identity,
|
|
128
129
|
load_users_file,
|
|
129
130
|
migrate_knowledge_graph_identity,
|
|
130
|
-
normalize_email,
|
|
131
131
|
save_users_file,
|
|
132
132
|
user_id_for_email as _user_id_for_email,
|
|
133
133
|
)
|
|
@@ -813,42 +813,21 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
813
813
|
def clear_conversation(conversation_id: str, started_at: Optional[str] = None) -> Dict:
|
|
814
814
|
return CONVERSATIONS.clear_conversation(conversation_id, started_at=started_at)
|
|
815
815
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
return "admin"
|
|
832
|
-
first_email = next(iter(users), None)
|
|
833
|
-
return "admin" if first_email == normalized_email else "user"
|
|
834
|
-
|
|
835
|
-
def _extract_bearer_token(request: Request) -> Optional[str]:
|
|
836
|
-
auth = request.headers.get("Authorization", "")
|
|
837
|
-
if auth.startswith("Bearer "):
|
|
838
|
-
return auth[7:].strip()
|
|
839
|
-
return request.cookies.get("session_token")
|
|
840
|
-
|
|
841
|
-
def get_current_user(request: Request) -> Optional[str]:
|
|
842
|
-
token = _extract_bearer_token(request)
|
|
843
|
-
if token:
|
|
844
|
-
return get_session_email(token)
|
|
845
|
-
return None
|
|
846
|
-
|
|
847
|
-
def require_user(request: Request) -> str:
|
|
848
|
-
email = get_current_user(request)
|
|
849
|
-
if REQUIRE_AUTH and not email:
|
|
850
|
-
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|
|
851
|
-
return email or ""
|
|
816
|
+
_access_runtime = build_access_runtime(
|
|
817
|
+
config=CONFIG,
|
|
818
|
+
require_auth=REQUIRE_AUTH,
|
|
819
|
+
http_exception=HTTPException,
|
|
820
|
+
request_type=Request,
|
|
821
|
+
load_users=load_users,
|
|
822
|
+
get_session_email=get_session_email,
|
|
823
|
+
user_id_for_email=_user_id_for_email,
|
|
824
|
+
)
|
|
825
|
+
get_user_role = _access_runtime["get_user_role"]
|
|
826
|
+
_extract_bearer_token = _access_runtime["_extract_bearer_token"]
|
|
827
|
+
get_current_user = _access_runtime["get_current_user"]
|
|
828
|
+
require_user = _access_runtime["require_user"]
|
|
829
|
+
require_admin = _access_runtime["require_admin"]
|
|
830
|
+
public_user = _access_runtime["public_user"]
|
|
852
831
|
|
|
853
832
|
|
|
854
833
|
# ── Rate limiting & file validation — delegated to latticeai.core.security ────
|
|
@@ -917,35 +896,6 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
917
896
|
raise HTTPException(status_code=403, detail="승인된 파일 내용과 요청 내용이 다릅니다.")
|
|
918
897
|
|
|
919
898
|
|
|
920
|
-
def require_admin(request: Request) -> tuple[str, Dict]:
|
|
921
|
-
users = load_users()
|
|
922
|
-
if not REQUIRE_AUTH:
|
|
923
|
-
return "", users
|
|
924
|
-
token = _extract_bearer_token(request)
|
|
925
|
-
if token:
|
|
926
|
-
email = get_session_email(token)
|
|
927
|
-
if email:
|
|
928
|
-
role = get_user_role(email, users)
|
|
929
|
-
try:
|
|
930
|
-
require_capability(role, "admin:users")
|
|
931
|
-
return email, users
|
|
932
|
-
except PermissionError:
|
|
933
|
-
pass
|
|
934
|
-
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
|
|
935
|
-
|
|
936
|
-
def public_user(email: str, user: Dict, users: Dict) -> Dict:
|
|
937
|
-
role = get_user_role(email, users)
|
|
938
|
-
user_id = user.get("id") or _user_id_for_email(users, email)
|
|
939
|
-
return {
|
|
940
|
-
"id": user_id,
|
|
941
|
-
"email": email,
|
|
942
|
-
"identity": user_id,
|
|
943
|
-
"name": user.get("name", ""),
|
|
944
|
-
"nickname": user.get("nickname", ""),
|
|
945
|
-
"role": role,
|
|
946
|
-
"disabled": bool(user.get("disabled", False)),
|
|
947
|
-
}
|
|
948
|
-
|
|
949
899
|
def get_history_user(email: Optional[str], nickname: Optional[str] = None) -> Dict:
|
|
950
900
|
if not email:
|
|
951
901
|
return {"user_email": None, "user_nickname": nickname or None}
|
|
@@ -19,7 +19,7 @@ from pathlib import Path
|
|
|
19
19
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
WORKSPACE_OS_VERSION = "6.
|
|
22
|
+
WORKSPACE_OS_VERSION = "6.4.0"
|
|
23
23
|
|
|
24
24
|
# Workspace types separate single-user Personal workspaces from shared
|
|
25
25
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Access-control helper closures for the app factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_access_runtime(
|
|
9
|
+
*,
|
|
10
|
+
config: Any,
|
|
11
|
+
require_auth: bool,
|
|
12
|
+
http_exception: Any,
|
|
13
|
+
request_type: Any,
|
|
14
|
+
load_users: Callable[[], Dict],
|
|
15
|
+
get_session_email: Callable[[str], Optional[str]],
|
|
16
|
+
user_id_for_email: Callable[[Dict, str], str],
|
|
17
|
+
) -> Dict[str, Any]:
|
|
18
|
+
"""Build user/admin access helpers without changing legacy call signatures."""
|
|
19
|
+
|
|
20
|
+
from latticeai.core.policy import normalize_role, require_capability
|
|
21
|
+
from latticeai.core.users import normalize_email
|
|
22
|
+
|
|
23
|
+
def get_user_role(email: str, users: Optional[Dict] = None) -> str:
|
|
24
|
+
users = users or load_users()
|
|
25
|
+
identity = str(email or "")
|
|
26
|
+
normalized_email = normalize_email(identity)
|
|
27
|
+
user = users.get(normalized_email) or users.get(identity) or next(
|
|
28
|
+
(
|
|
29
|
+
item
|
|
30
|
+
for item in users.values()
|
|
31
|
+
if isinstance(item, dict) and item.get("id") == identity
|
|
32
|
+
),
|
|
33
|
+
{},
|
|
34
|
+
)
|
|
35
|
+
if isinstance(user, dict) and user.get("role"):
|
|
36
|
+
return normalize_role(user["role"])
|
|
37
|
+
admin_emails = {normalize_email(item) for item in config.admin_emails}
|
|
38
|
+
if normalized_email in admin_emails:
|
|
39
|
+
return "admin"
|
|
40
|
+
first_email = next(iter(users), None)
|
|
41
|
+
return "admin" if first_email == normalized_email else "user"
|
|
42
|
+
|
|
43
|
+
def extract_bearer_token(request: request_type) -> Optional[str]:
|
|
44
|
+
auth = request.headers.get("Authorization", "")
|
|
45
|
+
if auth.startswith("Bearer "):
|
|
46
|
+
return auth[7:].strip()
|
|
47
|
+
return request.cookies.get("session_token")
|
|
48
|
+
|
|
49
|
+
def get_current_user(request: request_type) -> Optional[str]:
|
|
50
|
+
token = extract_bearer_token(request)
|
|
51
|
+
if token:
|
|
52
|
+
return get_session_email(token)
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
def require_user(request: request_type) -> str:
|
|
56
|
+
email = get_current_user(request)
|
|
57
|
+
if require_auth and not email:
|
|
58
|
+
raise http_exception(status_code=401, detail="인증이 필요합니다.")
|
|
59
|
+
return email or ""
|
|
60
|
+
|
|
61
|
+
def require_admin(request: request_type) -> tuple[str, Dict]:
|
|
62
|
+
users = load_users()
|
|
63
|
+
if not require_auth:
|
|
64
|
+
return "", users
|
|
65
|
+
token = extract_bearer_token(request)
|
|
66
|
+
if token:
|
|
67
|
+
email = get_session_email(token)
|
|
68
|
+
if email:
|
|
69
|
+
role = get_user_role(email, users)
|
|
70
|
+
try:
|
|
71
|
+
require_capability(role, "admin:users")
|
|
72
|
+
return email, users
|
|
73
|
+
except PermissionError:
|
|
74
|
+
pass
|
|
75
|
+
raise http_exception(status_code=403, detail="관리자 권한이 필요합니다.")
|
|
76
|
+
|
|
77
|
+
def public_user(email: str, user: Dict, users: Dict) -> Dict:
|
|
78
|
+
role = get_user_role(email, users)
|
|
79
|
+
user_id = user.get("id") or user_id_for_email(users, email)
|
|
80
|
+
return {
|
|
81
|
+
"id": user_id,
|
|
82
|
+
"email": email,
|
|
83
|
+
"identity": user_id,
|
|
84
|
+
"name": user.get("name", ""),
|
|
85
|
+
"nickname": user.get("nickname", ""),
|
|
86
|
+
"role": role,
|
|
87
|
+
"disabled": bool(user.get("disabled", False)),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"get_user_role": get_user_role,
|
|
92
|
+
"_extract_bearer_token": extract_bearer_token,
|
|
93
|
+
"get_current_user": get_current_user,
|
|
94
|
+
"require_user": require_user,
|
|
95
|
+
"require_admin": require_admin,
|
|
96
|
+
"public_user": public_user,
|
|
97
|
+
}
|
|
@@ -133,8 +133,11 @@ class MemoryService:
|
|
|
133
133
|
|
|
134
134
|
# ── Memory Manager: sources / usage / health ──────────────────────────
|
|
135
135
|
def manager(self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
136
|
-
ws_mem = self._workspace_memories(user_email=user_email, workspace_id="personal")
|
|
137
|
-
|
|
136
|
+
ws_mem = self._workspace_memories(user_email=user_email, workspace_id=workspace_id or "personal")
|
|
137
|
+
if workspace_id is None:
|
|
138
|
+
project_mem = [m for m in self._all_memories() if (m.get("workspace_id") or "personal") != "personal"]
|
|
139
|
+
else:
|
|
140
|
+
project_mem = self._workspace_memories(user_email=user_email, workspace_id=workspace_id)
|
|
138
141
|
snaps = self._snapshots(workspace_id=workspace_id)
|
|
139
142
|
convs = self._conversations()
|
|
140
143
|
kg_stats = self._kg_stats()
|
|
@@ -268,10 +271,13 @@ class MemoryService:
|
|
|
268
271
|
# ── inspect a single tier ─────────────────────────────────────────────
|
|
269
272
|
def inspect(self, source: str, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
|
|
270
273
|
if source == "workspace":
|
|
271
|
-
items = self._workspace_memories(user_email=user_email, workspace_id="personal")[:limit]
|
|
274
|
+
items = self._workspace_memories(user_email=user_email, workspace_id=workspace_id or "personal")[:limit]
|
|
272
275
|
return {"source": source, "items": items, "count": len(items)}
|
|
273
276
|
if source == "project":
|
|
274
|
-
|
|
277
|
+
if workspace_id is None:
|
|
278
|
+
items = [m for m in self._all_memories() if (m.get("workspace_id") or "personal") != "personal"][:limit]
|
|
279
|
+
else:
|
|
280
|
+
items = self._workspace_memories(user_email=user_email, workspace_id=workspace_id)[:limit]
|
|
275
281
|
return {"source": source, "items": items, "count": len(items)}
|
|
276
282
|
if source == "agent":
|
|
277
283
|
items = self._snapshots(workspace_id=workspace_id)[:limit]
|
|
@@ -287,12 +293,39 @@ class MemoryService:
|
|
|
287
293
|
raise KeyError(source)
|
|
288
294
|
|
|
289
295
|
# ── mutating operations ───────────────────────────────────────────────
|
|
290
|
-
def prune(
|
|
296
|
+
def prune(
|
|
297
|
+
self,
|
|
298
|
+
*,
|
|
299
|
+
ids: Optional[List[str]] = None,
|
|
300
|
+
kind: Optional[str] = None,
|
|
301
|
+
user_email: Optional[str] = None,
|
|
302
|
+
workspace_id: Optional[str] = None,
|
|
303
|
+
) -> Dict[str, Any]:
|
|
304
|
+
# Ownership guard: a caller may only prune memories they own. Both the
|
|
305
|
+
# explicit-id and kind paths are intersected with the caller's own
|
|
306
|
+
# memories, so a forged id for another user's memory is refused, not
|
|
307
|
+
# silently deleted.
|
|
308
|
+
owned_ids = {
|
|
309
|
+
m["id"]
|
|
310
|
+
for m in self._workspace_memories(user_email=user_email, workspace_id=workspace_id)
|
|
311
|
+
if m.get("id")
|
|
312
|
+
}
|
|
291
313
|
removed: List[str] = []
|
|
292
|
-
|
|
314
|
+
skipped: List[str] = []
|
|
315
|
+
target_ids: List[str] = []
|
|
316
|
+
seen: set = set()
|
|
317
|
+
for mid in (ids or []):
|
|
318
|
+
if mid in seen:
|
|
319
|
+
continue
|
|
320
|
+
seen.add(mid)
|
|
321
|
+
if mid in owned_ids:
|
|
322
|
+
target_ids.append(mid)
|
|
323
|
+
else:
|
|
324
|
+
skipped.append(mid)
|
|
293
325
|
if kind:
|
|
294
|
-
for m in self._workspace_memories(user_email=user_email, workspace_id=
|
|
295
|
-
if m.get("kind") == kind and m.get("id"):
|
|
326
|
+
for m in self._workspace_memories(user_email=user_email, workspace_id=workspace_id):
|
|
327
|
+
if m.get("kind") == kind and m.get("id") and m["id"] not in seen:
|
|
328
|
+
seen.add(m["id"])
|
|
296
329
|
target_ids.append(m["id"])
|
|
297
330
|
for mid in target_ids:
|
|
298
331
|
try:
|
|
@@ -300,14 +333,22 @@ class MemoryService:
|
|
|
300
333
|
removed.append(mid)
|
|
301
334
|
except Exception:
|
|
302
335
|
continue
|
|
303
|
-
|
|
336
|
+
result: Dict[str, Any] = {"removed": removed, "count": len(removed)}
|
|
337
|
+
if skipped:
|
|
338
|
+
result["skipped"] = skipped
|
|
339
|
+
return result
|
|
304
340
|
|
|
305
|
-
def compact(
|
|
341
|
+
def compact(
|
|
342
|
+
self,
|
|
343
|
+
*,
|
|
344
|
+
user_email: Optional[str] = None,
|
|
345
|
+
workspace_id: Optional[str] = None,
|
|
346
|
+
) -> Dict[str, Any]:
|
|
306
347
|
"""Dedupe workspace memories with identical (kind, content)."""
|
|
307
348
|
seen: set = set()
|
|
308
349
|
removed: List[str] = []
|
|
309
350
|
# Oldest first so the first occurrence (oldest) is kept.
|
|
310
|
-
memories = list(reversed(self._workspace_memories(user_email=user_email, workspace_id=
|
|
351
|
+
memories = list(reversed(self._workspace_memories(user_email=user_email, workspace_id=workspace_id)))
|
|
311
352
|
for m in memories:
|
|
312
353
|
key = (m.get("kind"), str(m.get("content") or "").strip())
|
|
313
354
|
if key in seen:
|
|
@@ -332,21 +373,23 @@ class MemoryService:
|
|
|
332
373
|
return {"status": "error", "detail": str(exc)}
|
|
333
374
|
return {"status": "error", "detail": f"Unknown rebuild target: {target}"}
|
|
334
375
|
|
|
335
|
-
def clear(
|
|
376
|
+
def clear(
|
|
377
|
+
self,
|
|
378
|
+
*,
|
|
379
|
+
scope: str,
|
|
380
|
+
confirm: bool = False,
|
|
381
|
+
user_email: Optional[str] = None,
|
|
382
|
+
workspace_id: Optional[str] = None,
|
|
383
|
+
) -> Dict[str, Any]:
|
|
336
384
|
if not confirm:
|
|
337
385
|
raise ValueError("clear requires confirm=true")
|
|
338
386
|
if scope in WORKSPACE_KINDS:
|
|
339
|
-
result = self.prune(kind=scope, user_email=user_email)
|
|
387
|
+
result = self.prune(kind=scope, user_email=user_email, workspace_id=workspace_id)
|
|
340
388
|
return {"cleared": scope, **result}
|
|
341
389
|
if scope == "workspace":
|
|
342
|
-
ids = [m["id"] for m in self._workspace_memories(user_email=user_email, workspace_id=
|
|
343
|
-
result = self.prune(ids=ids, user_email=user_email)
|
|
390
|
+
ids = [m["id"] for m in self._workspace_memories(user_email=user_email, workspace_id=workspace_id) if m.get("id")]
|
|
391
|
+
result = self.prune(ids=ids, user_email=user_email, workspace_id=workspace_id)
|
|
344
392
|
return {"cleared": "workspace", **result}
|
|
345
393
|
if scope == "graph":
|
|
346
|
-
|
|
347
|
-
return {"status": "unavailable", "detail": "Knowledge graph disabled."}
|
|
348
|
-
try:
|
|
349
|
-
return {"cleared": "graph", "result": self._kg.clear_all()}
|
|
350
|
-
except Exception as exc:
|
|
351
|
-
return {"status": "error", "detail": str(exc)}
|
|
394
|
+
raise ValueError("graph clear is disabled from Memory Manager because it is not workspace-scoped")
|
|
352
395
|
raise ValueError(f"unsupported clear scope: {scope}")
|
|
@@ -34,6 +34,10 @@ class ToolRouterContext:
|
|
|
34
34
|
install_mcp: Any
|
|
35
35
|
mcp_public_item: Any
|
|
36
36
|
hooks: Any = None
|
|
37
|
+
# Resolves a caller email to their allowed workspace set (None = no scoping,
|
|
38
|
+
# i.e. single-user / no-auth mode). Threaded to the knowledge-graph router so
|
|
39
|
+
# its read endpoints enforce the same workspace boundary as /api/search.
|
|
40
|
+
allowed_workspaces_for: Any = None
|
|
37
41
|
|
|
38
42
|
|
|
39
43
|
@dataclass(frozen=True)
|