ltcai 4.6.1 → 4.7.2
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 +74 -40
- package/docs/CHANGELOG.md +141 -0
- package/docs/PRODUCT_DIRECTION_REVIEW.md +88 -0
- package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +42 -0
- package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +49 -0
- package/docs/V4_7_2_INTUITIVE_BRAIN_UX_REPORT.md +62 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +22 -19
- package/frontend/src/App.tsx +627 -8
- package/frontend/src/api/client.ts +11 -1
- package/frontend/src/components/ProductFlow.tsx +106 -51
- package/frontend/src/pages/System.tsx +1 -1
- package/frontend/src/styles.css +905 -81
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +86 -13
- package/lattice_brain/portability.py +82 -14
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +141 -6
- package/latticeai/api/chat.py +35 -13
- package/latticeai/app_factory.py +8 -4
- package/latticeai/core/audit.py +3 -2
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/package.json +2 -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 +5 -5
- package/static/app/assets/index-DdAB4yfa.js +16 -0
- package/static/app/assets/index-DdAB4yfa.js.map +1 -0
- package/static/app/assets/{index-7U86v70r.css → index-KlQ04wVv.css} +1 -1
- package/static/app/index.html +2 -2
- package/static/app/assets/index-D1jAPQws.js +0 -16
- package/static/app/assets/index-D1jAPQws.js.map +0 -1
package/lattice_brain/archive.py
CHANGED
|
@@ -50,6 +50,10 @@ def _now() -> str:
|
|
|
50
50
|
return datetime.now(timezone.utc).isoformat()
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def _stamp() -> str:
|
|
54
|
+
return _now().replace(":", "").replace("-", "").replace(".", "")[:15]
|
|
55
|
+
|
|
56
|
+
|
|
53
57
|
def _derive_key(passphrase: str, salt: bytes) -> bytes:
|
|
54
58
|
if not passphrase:
|
|
55
59
|
raise ValueError("A passphrase is required for encrypted .latticebrain archives.")
|
|
@@ -93,6 +97,78 @@ def _assert_safe_member(name: str) -> PurePosixPath:
|
|
|
93
97
|
return path
|
|
94
98
|
|
|
95
99
|
|
|
100
|
+
def _pre_restore_backup_dir(anchor: Path) -> Path:
|
|
101
|
+
backup_dir = anchor.parent / f"{anchor.name}.pre-restore-{_stamp()}"
|
|
102
|
+
index = 1
|
|
103
|
+
while backup_dir.exists():
|
|
104
|
+
backup_dir = anchor.parent / f"{anchor.name}.pre-restore-{_stamp()}-{index}"
|
|
105
|
+
index += 1
|
|
106
|
+
backup_dir.mkdir(parents=True)
|
|
107
|
+
return backup_dir
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _sqlite_siblings(db_path: Path) -> tuple[Path, Path, Path]:
|
|
111
|
+
return (db_path, Path(str(db_path) + "-wal"), Path(str(db_path) + "-shm"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _restore_sibling(path: Path, backup: Path) -> None:
|
|
115
|
+
if backup.exists():
|
|
116
|
+
shutil.copy2(backup, path)
|
|
117
|
+
elif path.exists():
|
|
118
|
+
path.unlink()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
|
|
122
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
tmp = dest.parent / f".{dest.name}.restore-{_stamp()}-{os.getpid()}.tmp"
|
|
124
|
+
shutil.copyfile(src, tmp)
|
|
125
|
+
backups: dict[Path, Path] = {}
|
|
126
|
+
try:
|
|
127
|
+
for sibling in _sqlite_siblings(dest):
|
|
128
|
+
if sibling.exists():
|
|
129
|
+
backup = backup_dir / sibling.name
|
|
130
|
+
shutil.copy2(sibling, backup)
|
|
131
|
+
backups[sibling] = backup
|
|
132
|
+
for sibling in _sqlite_siblings(dest)[1:]:
|
|
133
|
+
if sibling.exists():
|
|
134
|
+
sibling.unlink()
|
|
135
|
+
os.replace(tmp, dest)
|
|
136
|
+
except Exception:
|
|
137
|
+
if tmp.exists():
|
|
138
|
+
tmp.unlink()
|
|
139
|
+
for sibling in _sqlite_siblings(dest):
|
|
140
|
+
_restore_sibling(sibling, backups.get(sibling, backup_dir / sibling.name))
|
|
141
|
+
raise
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _rollback_sqlite_from_backup(dest: Path, backup_dir: Path) -> None:
|
|
145
|
+
for sibling in _sqlite_siblings(dest):
|
|
146
|
+
_restore_sibling(sibling, backup_dir / sibling.name)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _replace_tree_with_backup(src: Optional[Path], dest: Path, backup_dir: Path) -> None:
|
|
150
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
staged = dest.parent / f".{dest.name}.restore-{_stamp()}-{os.getpid()}"
|
|
152
|
+
backup = backup_dir / dest.name
|
|
153
|
+
if src and src.exists():
|
|
154
|
+
shutil.copytree(src, staged)
|
|
155
|
+
else:
|
|
156
|
+
staged.mkdir(parents=True)
|
|
157
|
+
try:
|
|
158
|
+
if dest.exists():
|
|
159
|
+
shutil.copytree(dest, backup)
|
|
160
|
+
shutil.rmtree(dest)
|
|
161
|
+
os.replace(staged, dest)
|
|
162
|
+
except Exception:
|
|
163
|
+
if staged.exists():
|
|
164
|
+
shutil.rmtree(staged)
|
|
165
|
+
if dest.exists():
|
|
166
|
+
shutil.rmtree(dest)
|
|
167
|
+
if backup.exists():
|
|
168
|
+
shutil.copytree(backup, dest)
|
|
169
|
+
raise
|
|
170
|
+
|
|
171
|
+
|
|
96
172
|
@dataclass(frozen=True)
|
|
97
173
|
class BrainArchivePaths:
|
|
98
174
|
db_path: Path
|
|
@@ -397,19 +473,15 @@ class EncryptedBrainArchive:
|
|
|
397
473
|
db_src = out / "knowledge_graph.sqlite"
|
|
398
474
|
if not db_src.exists():
|
|
399
475
|
raise ValueError("Archive payload is missing knowledge_graph.sqlite.")
|
|
400
|
-
target.db_path
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
if blobs_src.exists():
|
|
410
|
-
shutil.copytree(blobs_src, target.blob_dir)
|
|
411
|
-
else:
|
|
412
|
-
target.blob_dir.mkdir(parents=True, exist_ok=True)
|
|
476
|
+
backup_dir = _pre_restore_backup_dir(target.db_path)
|
|
477
|
+
try:
|
|
478
|
+
_replace_sqlite_atomically(db_src, target.db_path, backup_dir)
|
|
479
|
+
blobs_src = out / "blobs"
|
|
480
|
+
if target.blob_dir:
|
|
481
|
+
_replace_tree_with_backup(blobs_src if blobs_src.exists() else None, target.blob_dir, backup_dir)
|
|
482
|
+
except Exception:
|
|
483
|
+
_rollback_sqlite_from_backup(target.db_path, backup_dir)
|
|
484
|
+
raise
|
|
413
485
|
if target.data_dir:
|
|
414
486
|
data_src = out / "data"
|
|
415
487
|
exports_src = out / "workspace_exports"
|
|
@@ -439,6 +511,7 @@ class EncryptedBrainArchive:
|
|
|
439
511
|
"path": str(target.db_path),
|
|
440
512
|
"encrypted": True,
|
|
441
513
|
"verified": True,
|
|
514
|
+
"pre_restore_backup": str(backup_dir),
|
|
442
515
|
"manifest": manifest,
|
|
443
516
|
}
|
|
444
517
|
|
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import hashlib
|
|
17
17
|
import json
|
|
18
|
+
import os
|
|
18
19
|
import shutil
|
|
19
20
|
import tempfile
|
|
20
21
|
import zipfile
|
|
@@ -57,6 +58,78 @@ def _safe_zip_names(names) -> None:
|
|
|
57
58
|
raise ValueError(f"Backup archive contains unsafe path: {name}")
|
|
58
59
|
|
|
59
60
|
|
|
61
|
+
def _pre_restore_backup_dir(anchor: Path) -> Path:
|
|
62
|
+
backup_dir = anchor.parent / f"{anchor.name}.pre-restore-{_stamp()}"
|
|
63
|
+
index = 1
|
|
64
|
+
while backup_dir.exists():
|
|
65
|
+
backup_dir = anchor.parent / f"{anchor.name}.pre-restore-{_stamp()}-{index}"
|
|
66
|
+
index += 1
|
|
67
|
+
backup_dir.mkdir(parents=True)
|
|
68
|
+
return backup_dir
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _sqlite_siblings(db_path: Path) -> tuple[Path, Path, Path]:
|
|
72
|
+
return (db_path, Path(str(db_path) + "-wal"), Path(str(db_path) + "-shm"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _restore_sibling(path: Path, backup: Path) -> None:
|
|
76
|
+
if backup.exists():
|
|
77
|
+
shutil.copy2(backup, path)
|
|
78
|
+
elif path.exists():
|
|
79
|
+
path.unlink()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
|
|
83
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
tmp = dest.parent / f".{dest.name}.restore-{_stamp()}-{os.getpid()}.tmp"
|
|
85
|
+
shutil.copyfile(src, tmp)
|
|
86
|
+
backups: dict[Path, Path] = {}
|
|
87
|
+
try:
|
|
88
|
+
for sibling in _sqlite_siblings(dest):
|
|
89
|
+
if sibling.exists():
|
|
90
|
+
backup = backup_dir / sibling.name
|
|
91
|
+
shutil.copy2(sibling, backup)
|
|
92
|
+
backups[sibling] = backup
|
|
93
|
+
for sibling in _sqlite_siblings(dest)[1:]:
|
|
94
|
+
if sibling.exists():
|
|
95
|
+
sibling.unlink()
|
|
96
|
+
os.replace(tmp, dest)
|
|
97
|
+
except Exception:
|
|
98
|
+
if tmp.exists():
|
|
99
|
+
tmp.unlink()
|
|
100
|
+
for sibling in _sqlite_siblings(dest):
|
|
101
|
+
_restore_sibling(sibling, backups.get(sibling, backup_dir / sibling.name))
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _rollback_sqlite_from_backup(dest: Path, backup_dir: Path) -> None:
|
|
106
|
+
for sibling in _sqlite_siblings(dest):
|
|
107
|
+
_restore_sibling(sibling, backup_dir / sibling.name)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _replace_tree_with_backup(src: Optional[Path], dest: Path, backup_dir: Path) -> None:
|
|
111
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
staged = dest.parent / f".{dest.name}.restore-{_stamp()}-{os.getpid()}"
|
|
113
|
+
backup = backup_dir / dest.name
|
|
114
|
+
if src and src.exists():
|
|
115
|
+
shutil.copytree(src, staged)
|
|
116
|
+
else:
|
|
117
|
+
staged.mkdir(parents=True)
|
|
118
|
+
try:
|
|
119
|
+
if dest.exists():
|
|
120
|
+
shutil.copytree(dest, backup)
|
|
121
|
+
shutil.rmtree(dest)
|
|
122
|
+
os.replace(staged, dest)
|
|
123
|
+
except Exception:
|
|
124
|
+
if staged.exists():
|
|
125
|
+
shutil.rmtree(staged)
|
|
126
|
+
if dest.exists():
|
|
127
|
+
shutil.rmtree(dest)
|
|
128
|
+
if backup.exists():
|
|
129
|
+
shutil.copytree(backup, dest)
|
|
130
|
+
raise
|
|
131
|
+
|
|
132
|
+
|
|
60
133
|
class KGPortabilityService:
|
|
61
134
|
def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True, device_identity: Any = None) -> None:
|
|
62
135
|
self._kg = knowledge_graph
|
|
@@ -204,24 +277,19 @@ class KGPortabilityService:
|
|
|
204
277
|
}
|
|
205
278
|
db_dest = Path(self._kg.db_path)
|
|
206
279
|
blob_dest = Path(self._kg.blob_dir)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if blob_src.exists():
|
|
216
|
-
if blob_dest.exists():
|
|
217
|
-
shutil.rmtree(blob_dest)
|
|
218
|
-
shutil.copytree(blob_src, blob_dest)
|
|
219
|
-
else:
|
|
220
|
-
blob_dest.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
backup_dir = _pre_restore_backup_dir(db_dest)
|
|
281
|
+
try:
|
|
282
|
+
_replace_sqlite_atomically(db_src, db_dest, backup_dir)
|
|
283
|
+
blob_src = tmp / "blobs"
|
|
284
|
+
_replace_tree_with_backup(blob_src if blob_src.exists() else None, blob_dest, backup_dir)
|
|
285
|
+
except Exception:
|
|
286
|
+
_rollback_sqlite_from_backup(db_dest, backup_dir)
|
|
287
|
+
raise
|
|
221
288
|
stats = self._kg.stats()
|
|
222
289
|
return {
|
|
223
290
|
"restored": True,
|
|
224
291
|
"manifest": manifest,
|
|
292
|
+
"pre_restore_backup": str(backup_dir),
|
|
225
293
|
"nodes": sum(stats.get("nodes", {}).values()),
|
|
226
294
|
}
|
|
227
295
|
|
|
@@ -14,7 +14,7 @@ from datetime import datetime
|
|
|
14
14
|
from typing import Any, Callable, Dict, List, Optional
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
MULTI_AGENT_VERSION = "4.
|
|
17
|
+
MULTI_AGENT_VERSION = "4.7.2"
|
|
18
18
|
|
|
19
19
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
20
20
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/admin.py
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
from collections import defaultdict
|
|
5
|
+
from datetime import datetime, timedelta
|
|
5
6
|
from typing import Callable, Dict, List, Optional
|
|
6
7
|
|
|
7
|
-
from fastapi import APIRouter, HTTPException, Request
|
|
8
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
8
9
|
from pydantic import BaseModel
|
|
9
10
|
|
|
11
|
+
from latticeai.core.workspace_os import DEFAULT_WORKSPACE_ID
|
|
12
|
+
|
|
10
13
|
|
|
11
14
|
class AdminUserUpdate(BaseModel):
|
|
12
15
|
role: Optional[str] = None
|
|
@@ -42,6 +45,7 @@ def create_admin_router(
|
|
|
42
45
|
save_users: Callable,
|
|
43
46
|
get_user_role: Callable,
|
|
44
47
|
get_history: Callable,
|
|
48
|
+
get_audit_log: Callable,
|
|
45
49
|
public_user: Callable,
|
|
46
50
|
load_vpc_config: Callable,
|
|
47
51
|
save_vpc_config: Callable,
|
|
@@ -60,10 +64,89 @@ def create_admin_router(
|
|
|
60
64
|
) -> APIRouter:
|
|
61
65
|
router = APIRouter()
|
|
62
66
|
|
|
67
|
+
def _workspace_scope(request: Request) -> Optional[str]:
|
|
68
|
+
header = request.headers.get("X-Workspace-Id")
|
|
69
|
+
if header and header.strip():
|
|
70
|
+
return header.strip()
|
|
71
|
+
query = request.query_params.get("workspace_id")
|
|
72
|
+
return query.strip() if query and query.strip() else None
|
|
73
|
+
|
|
74
|
+
def _matches_scope(item: Dict[str, object], workspace_id: Optional[str]) -> bool:
|
|
75
|
+
if not workspace_id:
|
|
76
|
+
return True
|
|
77
|
+
item_scope = item.get("workspace_id")
|
|
78
|
+
if not item_scope and workspace_id == DEFAULT_WORKSPACE_ID:
|
|
79
|
+
return True
|
|
80
|
+
return str(item_scope or "") == str(workspace_id)
|
|
81
|
+
|
|
82
|
+
def _scoped_history(request: Request) -> List[Dict]:
|
|
83
|
+
scope = _workspace_scope(request)
|
|
84
|
+
return [item for item in get_history() if _matches_scope(item, scope)]
|
|
85
|
+
|
|
86
|
+
def _scoped_audit_log(request: Request) -> List[Dict]:
|
|
87
|
+
scope = _workspace_scope(request)
|
|
88
|
+
return [item for item in get_audit_log() if _matches_scope(item, scope)]
|
|
89
|
+
|
|
90
|
+
def _filter_audit_log(
|
|
91
|
+
events: List[Dict],
|
|
92
|
+
*,
|
|
93
|
+
q: Optional[str] = None,
|
|
94
|
+
actor: Optional[str] = None,
|
|
95
|
+
action: Optional[str] = None,
|
|
96
|
+
severity: Optional[str] = None,
|
|
97
|
+
limit: int = 50,
|
|
98
|
+
) -> List[Dict]:
|
|
99
|
+
needle = (q or "").strip().lower()
|
|
100
|
+
actor_filter = (actor or "").strip().lower()
|
|
101
|
+
action_filter = (action or "").strip().lower()
|
|
102
|
+
severity_filter = (severity or "").strip().lower()
|
|
103
|
+
|
|
104
|
+
def matches(event: Dict) -> bool:
|
|
105
|
+
public = _event_public_text(event)
|
|
106
|
+
if needle and needle not in public:
|
|
107
|
+
return False
|
|
108
|
+
if actor_filter and actor_filter not in str(event.get("user_email") or event.get("actor") or "").lower():
|
|
109
|
+
return False
|
|
110
|
+
event_action = str(event.get("event_type") or event.get("action") or "").lower()
|
|
111
|
+
if action_filter and action_filter not in event_action:
|
|
112
|
+
return False
|
|
113
|
+
event_severity = str(event.get("severity") or event.get("sev") or "").lower()
|
|
114
|
+
if severity_filter and event_severity != severity_filter:
|
|
115
|
+
return False
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
capped_limit = max(1, min(int(limit or 50), 250))
|
|
119
|
+
return [event for event in events if matches(event)][-capped_limit:]
|
|
120
|
+
|
|
121
|
+
def _event_public_text(event: Dict) -> str:
|
|
122
|
+
parts = [
|
|
123
|
+
event.get("event_type"),
|
|
124
|
+
event.get("action"),
|
|
125
|
+
event.get("user_email"),
|
|
126
|
+
event.get("actor"),
|
|
127
|
+
event.get("target"),
|
|
128
|
+
event.get("target_email"),
|
|
129
|
+
event.get("workspace_id"),
|
|
130
|
+
event.get("severity"),
|
|
131
|
+
event.get("sev"),
|
|
132
|
+
]
|
|
133
|
+
return " ".join(str(part).lower() for part in parts if part is not None)
|
|
134
|
+
|
|
135
|
+
def _parse_timestamp(value: object) -> Optional[datetime]:
|
|
136
|
+
if not value:
|
|
137
|
+
return None
|
|
138
|
+
try:
|
|
139
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
140
|
+
if parsed.tzinfo is not None:
|
|
141
|
+
return parsed.astimezone().replace(tzinfo=None)
|
|
142
|
+
return parsed
|
|
143
|
+
except ValueError:
|
|
144
|
+
return None
|
|
145
|
+
|
|
63
146
|
@router.get("/admin/summary")
|
|
64
147
|
async def admin_summary(request: Request):
|
|
65
148
|
_, users = require_admin(request)
|
|
66
|
-
history =
|
|
149
|
+
history = _scoped_history(request)
|
|
67
150
|
user_msgs = [i for i in history if i.get("role") == "user"]
|
|
68
151
|
asst_msgs = [i for i in history if i.get("role") == "assistant"]
|
|
69
152
|
return {
|
|
@@ -79,7 +162,7 @@ def create_admin_router(
|
|
|
79
162
|
@router.get("/admin/stats")
|
|
80
163
|
async def admin_stats(request: Request):
|
|
81
164
|
require_admin(request)
|
|
82
|
-
history =
|
|
165
|
+
history = _scoped_history(request)
|
|
83
166
|
daily: dict = defaultdict(lambda: {"user": 0, "assistant": 0})
|
|
84
167
|
for item in history:
|
|
85
168
|
ts = item.get("timestamp", "")
|
|
@@ -98,12 +181,37 @@ def create_admin_router(
|
|
|
98
181
|
@router.get("/admin/sensitivity")
|
|
99
182
|
async def admin_sensitivity(request: Request):
|
|
100
183
|
require_admin(request)
|
|
101
|
-
return build_sensitivity_report(
|
|
184
|
+
return build_sensitivity_report(_scoped_history(request))
|
|
102
185
|
|
|
103
186
|
@router.get("/admin/audit")
|
|
104
|
-
async def admin_audit(
|
|
187
|
+
async def admin_audit(
|
|
188
|
+
request: Request,
|
|
189
|
+
q: Optional[str] = Query(None),
|
|
190
|
+
actor: Optional[str] = Query(None),
|
|
191
|
+
action: Optional[str] = Query(None),
|
|
192
|
+
severity: Optional[str] = Query(None),
|
|
193
|
+
limit: int = Query(50, ge=1, le=250),
|
|
194
|
+
):
|
|
105
195
|
_, users = require_admin(request)
|
|
106
|
-
|
|
196
|
+
scoped_events = _scoped_audit_log(request)
|
|
197
|
+
filtered_events = _filter_audit_log(
|
|
198
|
+
scoped_events,
|
|
199
|
+
q=q,
|
|
200
|
+
actor=actor,
|
|
201
|
+
action=action,
|
|
202
|
+
severity=severity,
|
|
203
|
+
limit=limit,
|
|
204
|
+
)
|
|
205
|
+
report = build_admin_audit_report(users, filtered_events)
|
|
206
|
+
report["filters"] = {
|
|
207
|
+
"q": q or "",
|
|
208
|
+
"actor": actor or "",
|
|
209
|
+
"action": action or "",
|
|
210
|
+
"severity": severity or "",
|
|
211
|
+
"limit": limit,
|
|
212
|
+
"matched_events": len(filtered_events),
|
|
213
|
+
"scoped_events": len(scoped_events),
|
|
214
|
+
}
|
|
107
215
|
try:
|
|
108
216
|
report["graph"] = get_graph_stats() if enable_graph else {"disabled": True}
|
|
109
217
|
except Exception as e:
|
|
@@ -153,9 +261,36 @@ def create_admin_router(
|
|
|
153
261
|
{"id": "invite_gate", "label": "Invite gate",
|
|
154
262
|
"value": "Required for new accounts" if invite_gate_enabled else "Open registration",
|
|
155
263
|
"enforced": bool(invite_gate_enabled)},
|
|
264
|
+
{"id": "log_retention", "label": "Log retention",
|
|
265
|
+
"value": "90 day local audit window with manual export before pruning", "enforced": True},
|
|
156
266
|
]
|
|
157
267
|
}
|
|
158
268
|
|
|
269
|
+
@router.get("/admin/log-retention")
|
|
270
|
+
async def admin_log_retention(request: Request):
|
|
271
|
+
require_admin(request)
|
|
272
|
+
events = _scoped_audit_log(request)
|
|
273
|
+
retention_days = 90
|
|
274
|
+
cutoff = datetime.now() - timedelta(days=retention_days)
|
|
275
|
+
retained = 0
|
|
276
|
+
prune_candidates = 0
|
|
277
|
+
for event in events:
|
|
278
|
+
ts = _parse_timestamp(event.get("timestamp") or event.get("ts"))
|
|
279
|
+
if ts and ts < cutoff:
|
|
280
|
+
prune_candidates += 1
|
|
281
|
+
else:
|
|
282
|
+
retained += 1
|
|
283
|
+
return {
|
|
284
|
+
"mode": "local-first",
|
|
285
|
+
"retention_days": retention_days,
|
|
286
|
+
"total_events": len(events),
|
|
287
|
+
"retained_events": retained,
|
|
288
|
+
"prune_candidates": prune_candidates,
|
|
289
|
+
"export_before_prune": True,
|
|
290
|
+
"editable": False,
|
|
291
|
+
"reason": "Retention is reported in Community mode; destructive pruning requires an explicit export workflow.",
|
|
292
|
+
}
|
|
293
|
+
|
|
159
294
|
@router.get("/admin/product-hardening")
|
|
160
295
|
async def admin_product_hardening(request: Request):
|
|
161
296
|
require_admin(request)
|
package/latticeai/api/chat.py
CHANGED
|
@@ -52,6 +52,7 @@ class AgentRequest(BaseModel):
|
|
|
52
52
|
temperature: float = 0.1
|
|
53
53
|
user_email: Optional[str] = None
|
|
54
54
|
user_nickname: Optional[str] = None
|
|
55
|
+
workspace_id: Optional[str] = None
|
|
55
56
|
planning_model: Optional[str] = None
|
|
56
57
|
executing_model: Optional[str] = None
|
|
57
58
|
reviewing_model: Optional[str] = None
|
|
@@ -136,6 +137,13 @@ def format_network_status(info: Dict) -> str:
|
|
|
136
137
|
lines.extend(["", note])
|
|
137
138
|
return "\n".join(lines)
|
|
138
139
|
|
|
140
|
+
def workspace_scope_from_request(request: Request) -> Optional[str]:
|
|
141
|
+
header = request.headers.get("X-Workspace-Id")
|
|
142
|
+
if header and header.strip():
|
|
143
|
+
return header.strip()
|
|
144
|
+
query = request.query_params.get("workspace_id")
|
|
145
|
+
return query.strip() if query and query.strip() else None
|
|
146
|
+
|
|
139
147
|
async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[str]:
|
|
140
148
|
yield f"data: {json.dumps({'chunk': text, 'model': model}, ensure_ascii=False)}\n\n"
|
|
141
149
|
yield "data: [DONE]\n\n"
|
|
@@ -296,15 +304,20 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
296
304
|
)
|
|
297
305
|
effective_email = req.user_email or current_user or None
|
|
298
306
|
history_user = get_history_user(effective_email, req.user_nickname)
|
|
307
|
+
history_meta = {
|
|
308
|
+
"source": req.source or "web",
|
|
309
|
+
"conversation_id": req.conversation_id,
|
|
310
|
+
"workspace_id": workspace_scope_from_request(request),
|
|
311
|
+
}
|
|
299
312
|
|
|
300
313
|
if is_network_status_request(req.message):
|
|
301
314
|
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
302
|
-
save_to_history("user", history_message,
|
|
315
|
+
save_to_history("user", history_message, **history_meta, **history_user)
|
|
303
316
|
try:
|
|
304
317
|
answer = format_network_status(network_status())
|
|
305
318
|
except ToolError as exc:
|
|
306
319
|
answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
|
|
307
|
-
save_to_history("assistant", answer,
|
|
320
|
+
save_to_history("assistant", answer, **history_meta, **history_user)
|
|
308
321
|
notify_chat_message("user", req.message, req.source)
|
|
309
322
|
notify_chat_message("assistant", answer, req.source)
|
|
310
323
|
if req.stream:
|
|
@@ -362,8 +375,8 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
362
375
|
|
|
363
376
|
if is_current_url_request(req.message) and req.client_url:
|
|
364
377
|
answer = f"현재 페이지 URL: {req.client_url}"
|
|
365
|
-
save_to_history("user", req.message,
|
|
366
|
-
save_to_history("assistant", answer,
|
|
378
|
+
save_to_history("user", req.message, **history_meta, **history_user)
|
|
379
|
+
save_to_history("assistant", answer, **history_meta, **history_user)
|
|
367
380
|
notify_chat_message("user", req.message, req.source)
|
|
368
381
|
notify_chat_message("assistant", answer, req.source)
|
|
369
382
|
if req.stream:
|
|
@@ -459,7 +472,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
459
472
|
trace_seed["context_assembly"] = context_trace
|
|
460
473
|
|
|
461
474
|
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
462
|
-
save_to_history("user", history_message,
|
|
475
|
+
save_to_history("user", history_message, **history_meta, **history_user)
|
|
463
476
|
notify_chat_message("user", req.message, req.source)
|
|
464
477
|
|
|
465
478
|
if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
@@ -488,7 +501,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
488
501
|
yield f"data: {json.dumps({'text': footnote}, ensure_ascii=False)}\n\n"
|
|
489
502
|
full_text += footnote
|
|
490
503
|
session.update(graph_md, full_text, req.conversation_id)
|
|
491
|
-
save_to_history("assistant", full_text,
|
|
504
|
+
save_to_history("assistant", full_text, **history_meta, **history_user)
|
|
492
505
|
trace_record = CHAT_SERVICE.record_trace(
|
|
493
506
|
question=req.message,
|
|
494
507
|
response=full_text,
|
|
@@ -513,7 +526,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
513
526
|
if footnote:
|
|
514
527
|
result += footnote
|
|
515
528
|
session.update(graph_md, result, req.conversation_id)
|
|
516
|
-
save_to_history("assistant", str(result),
|
|
529
|
+
save_to_history("assistant", str(result), **history_meta, **history_user)
|
|
517
530
|
trace_record = CHAT_SERVICE.record_trace(
|
|
518
531
|
question=req.message,
|
|
519
532
|
response=str(result),
|
|
@@ -530,7 +543,14 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
530
543
|
if recent_context:
|
|
531
544
|
stream_context = f"[RECENT CONVERSATION]\n{recent_context}\n\n{context}".strip()
|
|
532
545
|
return StreamingResponse(
|
|
533
|
-
_stream_chat(
|
|
546
|
+
_stream_chat(
|
|
547
|
+
req,
|
|
548
|
+
stream_context,
|
|
549
|
+
req.image_data,
|
|
550
|
+
trace_seed=trace_seed,
|
|
551
|
+
effective_email=effective_email,
|
|
552
|
+
history_meta=history_meta,
|
|
553
|
+
),
|
|
534
554
|
media_type="text/event-stream",
|
|
535
555
|
headers={"X-Model": router.current_model_id},
|
|
536
556
|
)
|
|
@@ -549,7 +569,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
549
569
|
|
|
550
570
|
result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
|
|
551
571
|
|
|
552
|
-
save_to_history("assistant", str(result),
|
|
572
|
+
save_to_history("assistant", str(result), **history_meta, **history_user)
|
|
553
573
|
trace_record = CHAT_SERVICE.record_trace(
|
|
554
574
|
question=req.message,
|
|
555
575
|
response=str(result),
|
|
@@ -637,6 +657,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
637
657
|
*,
|
|
638
658
|
trace_seed: Optional[Dict] = None,
|
|
639
659
|
effective_email: Optional[str] = None,
|
|
660
|
+
history_meta: Optional[Dict] = None,
|
|
640
661
|
) -> AsyncIterator[str]:
|
|
641
662
|
full_response = ""
|
|
642
663
|
async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
|
|
@@ -651,8 +672,8 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
651
672
|
|
|
652
673
|
full_response += str(clean_chunk)
|
|
653
674
|
yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
|
|
654
|
-
history_user = get_history_user(req.user_email, req.user_nickname)
|
|
655
|
-
save_to_history("assistant", full_response,
|
|
675
|
+
history_user = get_history_user(effective_email or req.user_email, req.user_nickname)
|
|
676
|
+
save_to_history("assistant", full_response, **(history_meta or {}), **history_user)
|
|
656
677
|
trace_record = CHAT_SERVICE.record_trace(
|
|
657
678
|
question=req.message,
|
|
658
679
|
response=full_response,
|
|
@@ -728,6 +749,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
728
749
|
"""
|
|
729
750
|
current_user = require_user(request)
|
|
730
751
|
enforce_rate_limit(current_user, "agent")
|
|
752
|
+
req.workspace_id = req.workspace_id or workspace_scope_from_request(request)
|
|
731
753
|
if not router.current_model_id:
|
|
732
754
|
raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
|
|
733
755
|
|
|
@@ -776,8 +798,8 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
776
798
|
asyncio.create_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
|
|
777
799
|
|
|
778
800
|
message = ctx.final_message or "작업을 완료했습니다."
|
|
779
|
-
save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
780
|
-
save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
801
|
+
save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
|
|
802
|
+
save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
|
|
781
803
|
try:
|
|
782
804
|
WORKSPACE_OS.record_agent_run(
|
|
783
805
|
agent_id="agent:executor",
|