ltcai 10.6.3 → 10.8.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 +56 -59
- package/docs/CHANGELOG.md +68 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/LAYOUT_REBUILD_SPEC.md +810 -0
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +29 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/retrieval_vector.py +69 -33
- package/lattice_brain/runtime/multi_agent.py +96 -15
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +81 -6
- package/latticeai/api/auth.py +37 -38
- package/latticeai/api/automation_intelligence.py +112 -0
- package/latticeai/api/browser.py +7 -6
- package/latticeai/api/knowledge_graph.py +133 -0
- package/latticeai/api/static_routes.py +35 -1
- package/latticeai/core/file_generation.py +141 -8
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +230 -0
- package/latticeai/core/workspace_computer_memory.py +84 -0
- package/latticeai/core/workspace_indexing.py +102 -0
- package/latticeai/core/workspace_onboarding.py +104 -0
- package/latticeai/core/workspace_os.py +27 -207
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_relationships.py +99 -0
- package/latticeai/core/workspace_runs.py +111 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +5 -3
- package/scripts/capture_release_evidence.mjs +335 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_release_evidence_bound.mjs +116 -0
- package/scripts/check_screenshot_pixel_delta.py +517 -0
- package/scripts/measure_brain_home_fill.mjs +141 -0
- package/scripts/release_screen_claims.json +37 -0
- 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 +37 -39
- package/static/app/assets/{Act-05uZSgyw.js → Act-CbQNsFgc.js} +1 -1
- package/static/app/assets/AdminConsole-Bs8SR8fE.js +1 -0
- package/static/app/assets/{Brain-jjMYTdnU.js → Brain-BZIPsZlH.js} +2 -2
- package/static/app/assets/BrainHome-BaH5MDce.js +2 -0
- package/static/app/assets/{BrainSignals-BYxDqBH3.js → BrainSignals-aDt-nSsW.js} +1 -1
- package/static/app/assets/Capture-DDv940Bv.js +1 -0
- package/static/app/assets/{CommandPalette-Cj7z-qUE.js → CommandPalette-Dq_MmPBl.js} +1 -1
- package/static/app/assets/Library-BkShbqmN.js +1 -0
- package/static/app/assets/{LivingBrain-CTokkZn1.js → LivingBrain-CXb8094H.js} +1 -1
- package/static/app/assets/ProductFlow-BaiqFWLO.js +1 -0
- package/static/app/assets/{ReviewCard-uAII_l9v.js → ReviewCard-Be7E7X18.js} +2 -2
- package/static/app/assets/System-DCstbTxC.js +1 -0
- package/static/app/assets/arrow-left-DC9Oquhs.js +1 -0
- package/static/app/assets/{bot-CkrHFdUl.js → bot-BR0eoeXY.js} +1 -1
- package/static/app/assets/brain-BmWa1wea.js +1 -0
- package/static/app/assets/{button-Ic6omIbs.js → button-U1nU7xX4.js} +1 -1
- package/static/app/assets/{circle-pause-DBaaUR08.js → circle-pause-DG5KHjDL.js} +1 -1
- package/static/app/assets/{circle-play-Dz0OLYN9.js → circle-play-Chhcuq90.js} +1 -1
- package/static/app/assets/{cpu-N8uE9ono.js → cpu-pkOSlT9h.js} +1 -1
- package/static/app/assets/{download-CCjhOjms.js → download-CSRwz923.js} +1 -1
- package/static/app/assets/{folder-open-Bld9TtHp.js → folder-open-DiMJ7W_7.js} +1 -1
- package/static/app/assets/{hard-drive-BMIqDSyY.js → hard-drive-iyLPAPdp.js} +1 -1
- package/static/app/assets/index-BZRvGOM2.css +2 -0
- package/static/app/assets/{index-yNtiRyw3.js → index-_ldE9TAX.js} +4 -4
- package/static/app/assets/{input-DW1tXtc0.js → input-e_r2hFAY.js} +1 -1
- package/static/app/assets/{permissionCopy-Djpwn54H.js → permissionCopy-9uOHktz3.js} +1 -1
- package/static/app/assets/{primitives-B6WtkLgB.js → primitives-CnEAOmg2.js} +1 -1
- package/static/app/assets/search-veep4n6f.js +1 -0
- package/static/app/assets/{share-2-DR5L1qRc.js → share-2-DLDJJKmI.js} +1 -1
- package/static/app/assets/{shield-alert-CVE5eXsl.js → shield-alert-BTtE-2EZ.js} +1 -1
- package/static/app/assets/{textarea-DdaVFgvk.js → textarea-Bbzkcgqe.js} +1 -1
- package/static/app/assets/{useFocusTrap-B5TyGaiB.js → useFocusTrap-DJzQd-Za.js} +1 -1
- package/static/app/assets/{useQuery-CiRdTQlD.js → useQuery-6R_tqHRe.js} +1 -1
- package/static/app/assets/utils-XDsjcrhS.js +4 -0
- package/static/app/assets/workspace-CwrG8LHq.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/AdminConsole-C8yz1Cs_.js +0 -1
- package/static/app/assets/BrainHome-D3t45_D4.js +0 -2
- package/static/app/assets/Capture-DKgu7fB0.js +0 -1
- package/static/app/assets/Library-CO76FWeO.js +0 -1
- package/static/app/assets/ProductFlow-Bmted2ys.js +0 -1
- package/static/app/assets/System-PUMzDmof.js +0 -1
- package/static/app/assets/activity-C_7182GG.js +0 -1
- package/static/app/assets/arrow-left-BQCpWR37.js +0 -1
- package/static/app/assets/brain-CdPX7svt.js +0 -1
- package/static/app/assets/index-DYUs0cWy.css +0 -2
- package/static/app/assets/search-CkHYuKpY.js +0 -1
- package/static/app/assets/users-pZqCRxdD.js +0 -1
- package/static/app/assets/utils-C3rXoa-U.js +0 -4
- package/static/app/assets/workspace-DfxKEttN.js +0 -1
package/docs/ONBOARDING.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Lattice AI Onboarding
|
|
2
2
|
|
|
3
|
-
Current release: **10.
|
|
3
|
+
Current release: **10.8.0 — Within Reach**.
|
|
4
4
|
|
|
5
5
|
The first-run goal is a five-minute path from "I opened the app" to "my Brain
|
|
6
6
|
has a source, a question, and proof." This page is the product contract behind
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Lattice AI — Operations Guide (v10.
|
|
1
|
+
# Lattice AI — Operations Guide (v10.8.0)
|
|
2
2
|
|
|
3
3
|
> **Status: canonical** — kept in sync with the current release. Storage layout
|
|
4
4
|
> below reflects the SQLite live Brain store and workspace scoping, not the
|
|
@@ -162,3 +162,31 @@ Ctrl+C 또는 kill <pid>
|
|
|
162
162
|
```
|
|
163
163
|
|
|
164
164
|
서버는 종료 시 진행 중인 HTTP 요청을 완료한 후 종료됩니다 (graceful shutdown — uvicorn 기본 동작).
|
|
165
|
+
|
|
166
|
+
## 10. 릴리스 증거 캡처 순서 (레이아웃·UI 변경 후)
|
|
167
|
+
|
|
168
|
+
`scripts/capture_release_evidence.mjs` 의 `assertNotOverwritingTaggedRelease()` 는
|
|
169
|
+
`package.json` 버전과 동일한 git 태그(`vX.Y.Z`)가 이미 있으면 **즉시 exit 1** 한다.
|
|
170
|
+
레이아웃/UI를 바꾼 뒤 스크린샷을 다시 찍을 때는 반드시 아래 순서를 지킨다.
|
|
171
|
+
|
|
172
|
+
1. **버전 범프 먼저** — `node scripts/run_python.mjs scripts/bump_version.py`
|
|
173
|
+
(또는 동등한 package/pyproject/extension 버전 일괄 상향). 캡처 **전에** 한다.
|
|
174
|
+
2. `npm run build:assets` — 캡처는 빌드된 `static/app` 을 본다.
|
|
175
|
+
3. `npm run release:evidence` — `output/release/v<새버전>/` 에만 기록한다.
|
|
176
|
+
캡처 → 오래된 버전 정리 → **픽셀 델타 검사**까지 한 번에 돈다.
|
|
177
|
+
4. 결속 검사 — `SCREENSHOT_INDEX.md` 의 `asset-manifest.sha256` 과
|
|
178
|
+
`mock-server.sha256` 이 각각 `static/app/asset-manifest.json`,
|
|
179
|
+
`tests/visual/mock_server.cjs` 와 일치해야 `npm run lint` 가 통과한다.
|
|
180
|
+
|
|
181
|
+
레이아웃을 바꿨다고 보고했는데 배치는 그대로인 경우를 잡기 위해 v10.7.0 부터
|
|
182
|
+
`scripts/check_screenshot_pixel_delta.py` 가 12개 캡처를 직전 버전과 대조한다
|
|
183
|
+
(`npm run check:screenshot-delta` 로 단독 실행). 정적 화면은 1.5%, 애니메이션이
|
|
184
|
+
있는 화면은 8% 이상 픽셀이 달라져야 통과한다. 문구만 고친 화면은 여기서 실패로
|
|
185
|
+
잡히므로, 실패했다면 캡처가 아니라 레이아웃을 다시 본다. 직전 버전 디렉터리가
|
|
186
|
+
정리되어 없으면 기준선을 꺼낼 태그를 체크아웃한 뒤 다시 돌린다.
|
|
187
|
+
|
|
188
|
+
이미 태그가 찍힌 버전의 `output/release/vX.Y.Z/` 를 WIP UI로 덮으면 그 릴리스
|
|
189
|
+
증거가 영구히 거짓이 된다. 미리보기만 필요하면
|
|
190
|
+
`LTCAI_RELEASE_EVIDENCE_DIR=/tmp/lattice-preview npm run release:evidence` 로
|
|
191
|
+
게시 경로 밖을 쓴다. 태그된 버전의 원본 증거가 오염됐다면 태그 시점 트리에서
|
|
192
|
+
해당 디렉터리를 복원한 뒤, 새 버전 디렉터리에 다시 캡처한다.
|
package/docs/TRUST_MODEL.md
CHANGED
package/docs/WHY_LATTICE.md
CHANGED
package/docs/kg-schema.md
CHANGED
|
@@ -136,14 +136,45 @@ class KnowledgeGraphVectorMixin(_Core):
|
|
|
136
136
|
)
|
|
137
137
|
return {"current": current, "recorded": recorded, "stale_embedder": stale}
|
|
138
138
|
|
|
139
|
+
def _vector_text_hashes(self, conn: sqlite3.Connection) -> Dict[str, str]:
|
|
140
|
+
"""``item_id -> text_hash`` for rows already embedded by *this* embedder.
|
|
141
|
+
|
|
142
|
+
The incremental rebuild's job is mostly deciding what it does *not*
|
|
143
|
+
have to do, and it used to ask that question with one ``SELECT`` per
|
|
144
|
+
candidate item — a round trip per node and per chunk on every run,
|
|
145
|
+
almost all of which answer "unchanged". One query returning two short
|
|
146
|
+
columns replaces all of them.
|
|
147
|
+
|
|
148
|
+
Rows written by a different embedder are left out, so they compare as
|
|
149
|
+
missing and get re-embedded, which is what an embedder swap requires.
|
|
150
|
+
"""
|
|
151
|
+
return {
|
|
152
|
+
row["item_id"]: row["text_hash"]
|
|
153
|
+
for row in conn.execute(
|
|
154
|
+
"""
|
|
155
|
+
SELECT item_id, text_hash
|
|
156
|
+
FROM vector_embeddings
|
|
157
|
+
WHERE embedding_model=? AND embedding_dim=?
|
|
158
|
+
""",
|
|
159
|
+
(self._embedding_model.model_id, self._embedding_model.dim),
|
|
160
|
+
).fetchall()
|
|
161
|
+
}
|
|
162
|
+
|
|
139
163
|
def _iter_vector_source_items(
|
|
140
164
|
self,
|
|
141
165
|
conn: sqlite3.Connection,
|
|
142
166
|
*,
|
|
143
167
|
include_nodes: bool = True,
|
|
144
168
|
include_chunks: bool = True,
|
|
145
|
-
) ->
|
|
146
|
-
|
|
169
|
+
) -> Iterator[Dict[str, Any]]:
|
|
170
|
+
"""Stream the graph's embeddable text, one item at a time.
|
|
171
|
+
|
|
172
|
+
Yields rather than returns a list. Every caller consumes this exactly
|
|
173
|
+
once in a ``for``, and building the list first meant a rebuild held
|
|
174
|
+
the full text of every node and chunk in memory simultaneously — the
|
|
175
|
+
one shape guaranteed to fail on precisely the large graph that most
|
|
176
|
+
needs the index.
|
|
177
|
+
"""
|
|
147
178
|
if include_nodes:
|
|
148
179
|
for row in conn.execute(
|
|
149
180
|
"""
|
|
@@ -160,15 +191,13 @@ class KnowledgeGraphVectorMixin(_Core):
|
|
|
160
191
|
metadata=metadata,
|
|
161
192
|
)
|
|
162
193
|
if text:
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
)
|
|
194
|
+
yield {
|
|
195
|
+
"item_id": row["id"],
|
|
196
|
+
"item_type": "node",
|
|
197
|
+
"source_node": row["id"],
|
|
198
|
+
"text": text,
|
|
199
|
+
"metadata": {"node_type": row["type"], **metadata},
|
|
200
|
+
}
|
|
172
201
|
if include_chunks:
|
|
173
202
|
for row in conn.execute(
|
|
174
203
|
"""
|
|
@@ -181,19 +210,16 @@ class KnowledgeGraphVectorMixin(_Core):
|
|
|
181
210
|
metadata = _safe_loads(row["metadata_json"])
|
|
182
211
|
text = _clean_text(row["text"] or "")
|
|
183
212
|
if text:
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
)
|
|
196
|
-
return items
|
|
213
|
+
yield {
|
|
214
|
+
"item_id": row["id"],
|
|
215
|
+
"item_type": "chunk",
|
|
216
|
+
"source_node": row["id"],
|
|
217
|
+
"text": text,
|
|
218
|
+
"metadata": {
|
|
219
|
+
**metadata,
|
|
220
|
+
"parent_source_node": row["parent_source_node"],
|
|
221
|
+
},
|
|
222
|
+
}
|
|
197
223
|
|
|
198
224
|
def index_node_incremental(self, node_id: str) -> Dict[str, Any]:
|
|
199
225
|
"""Embed/index only ``node_id`` and its chunks (incremental sync).
|
|
@@ -345,15 +371,22 @@ class KnowledgeGraphVectorMixin(_Core):
|
|
|
345
371
|
conn.execute(
|
|
346
372
|
f"DELETE FROM vector_embeddings WHERE item_type IN ({','.join(filters)})"
|
|
347
373
|
)
|
|
348
|
-
|
|
374
|
+
# After a full wipe nothing is current by definition, so the
|
|
375
|
+
# prefetch would only be a wasted scan of a table we just
|
|
376
|
+
# emptied. Incremental is where it pays: it turns "one SELECT
|
|
377
|
+
# per item, nearly all of which say unchanged" into one query.
|
|
378
|
+
known = {} if full else self._vector_text_hashes(conn)
|
|
379
|
+
total = indexed = skipped = 0
|
|
380
|
+
for item in self._iter_vector_source_items(
|
|
349
381
|
conn,
|
|
350
382
|
include_nodes=include_nodes,
|
|
351
383
|
include_chunks=include_chunks,
|
|
352
|
-
)
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
384
|
+
):
|
|
385
|
+
total += 1
|
|
386
|
+
if known.get(item["item_id"]) == _sha256_text(_clean_text(item["text"])):
|
|
387
|
+
skipped += 1
|
|
388
|
+
continue
|
|
389
|
+
if self._upsert_vector_item(conn, **item):
|
|
357
390
|
indexed += 1
|
|
358
391
|
else:
|
|
359
392
|
skipped += 1
|
|
@@ -367,7 +400,7 @@ class KnowledgeGraphVectorMixin(_Core):
|
|
|
367
400
|
""",
|
|
368
401
|
(
|
|
369
402
|
_now(),
|
|
370
|
-
|
|
403
|
+
total,
|
|
371
404
|
indexed,
|
|
372
405
|
skipped,
|
|
373
406
|
_json(
|
|
@@ -390,7 +423,7 @@ class KnowledgeGraphVectorMixin(_Core):
|
|
|
390
423
|
"status": "completed",
|
|
391
424
|
"operation_id": op_id,
|
|
392
425
|
"full": bool(full),
|
|
393
|
-
"items_total":
|
|
426
|
+
"items_total": total,
|
|
394
427
|
"items_indexed": indexed,
|
|
395
428
|
"items_skipped": skipped,
|
|
396
429
|
"duration_ms": duration_ms,
|
|
@@ -442,7 +475,10 @@ class KnowledgeGraphVectorMixin(_Core):
|
|
|
442
475
|
"SELECT item_type, COUNT(*) AS count FROM vector_embeddings GROUP BY item_type"
|
|
443
476
|
)
|
|
444
477
|
}
|
|
445
|
-
|
|
478
|
+
# Materialised on purpose, unlike the rebuild path: status walks
|
|
479
|
+
# this set twice (once for ids, once to classify each item) and
|
|
480
|
+
# reports a count, none of which a one-shot iterator can serve.
|
|
481
|
+
source_items = list(self._iter_vector_source_items(conn))
|
|
446
482
|
vector_rows = {
|
|
447
483
|
row["item_id"]: row
|
|
448
484
|
for row in conn.execute(
|
|
@@ -41,13 +41,15 @@ orchestrator for the product ``/agents`` surface.
|
|
|
41
41
|
|
|
42
42
|
from __future__ import annotations
|
|
43
43
|
|
|
44
|
+
import json
|
|
45
|
+
import re
|
|
44
46
|
from dataclasses import dataclass, field
|
|
45
47
|
from typing import Any, Callable, Dict, List, Optional
|
|
46
48
|
|
|
47
49
|
from ..utils import now_iso as _now
|
|
48
50
|
from .contracts import multi_agent_contract
|
|
49
51
|
|
|
50
|
-
MULTI_AGENT_VERSION = "10.
|
|
52
|
+
MULTI_AGENT_VERSION = "10.8.0"
|
|
51
53
|
|
|
52
54
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
53
55
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
|
@@ -448,23 +450,102 @@ def default_role_runner(
|
|
|
448
450
|
return runner
|
|
449
451
|
|
|
450
452
|
|
|
453
|
+
def _json_object_candidates(text: str) -> List[str]:
|
|
454
|
+
"""Every balanced ``{...}`` span in ``text``, outermost first.
|
|
455
|
+
|
|
456
|
+
``find("{")`` to ``rfind("}")`` is one span, and it is the wrong one
|
|
457
|
+
whenever the model wrote anything after its object — a closing remark, a
|
|
458
|
+
second example, a stray ``}`` in prose — because the slice then contains
|
|
459
|
+
the object *and* the noise, and parses as neither. Scanning for balance
|
|
460
|
+
yields the actual objects, in the order they appear.
|
|
461
|
+
|
|
462
|
+
String literals are tracked so a brace inside ``"a } b"`` does not close a
|
|
463
|
+
span early.
|
|
464
|
+
"""
|
|
465
|
+
spans: List[str] = []
|
|
466
|
+
depth = 0
|
|
467
|
+
start = -1
|
|
468
|
+
in_string = False
|
|
469
|
+
escaped = False
|
|
470
|
+
for index, char in enumerate(text):
|
|
471
|
+
if in_string:
|
|
472
|
+
if escaped:
|
|
473
|
+
escaped = False
|
|
474
|
+
elif char == "\\":
|
|
475
|
+
escaped = True
|
|
476
|
+
elif char == '"':
|
|
477
|
+
in_string = False
|
|
478
|
+
continue
|
|
479
|
+
if char == '"':
|
|
480
|
+
in_string = True
|
|
481
|
+
elif char == "{":
|
|
482
|
+
if depth == 0:
|
|
483
|
+
start = index
|
|
484
|
+
depth += 1
|
|
485
|
+
elif char == "}":
|
|
486
|
+
if depth:
|
|
487
|
+
depth -= 1
|
|
488
|
+
if depth == 0 and start >= 0:
|
|
489
|
+
spans.append(text[start : index + 1])
|
|
490
|
+
return spans
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
#: A comma directly before a closing brace/bracket. Small local models emit
|
|
494
|
+
#: these constantly — it is the single most common reason an otherwise correct
|
|
495
|
+
#: plan is thrown away.
|
|
496
|
+
_TRAILING_COMMA_RE = re.compile(r",(\s*[}\]])")
|
|
497
|
+
|
|
498
|
+
#: Reasoning models wrap their scratchpad in these, and the scratchpad is full
|
|
499
|
+
#: of braces. Removed before anything else so it cannot poison a span.
|
|
500
|
+
_THINK_BLOCK_RE = re.compile(
|
|
501
|
+
r"<(think|thinking|reasoning|reflection)>.*?</\1>",
|
|
502
|
+
re.DOTALL | re.IGNORECASE,
|
|
503
|
+
)
|
|
504
|
+
_THINK_OPEN_RE = re.compile(r"<(think|thinking|reasoning)>.*\Z", re.DOTALL | re.IGNORECASE)
|
|
505
|
+
|
|
506
|
+
|
|
451
507
|
def _extract_json_object(raw: str) -> Dict[str, Any]:
|
|
452
|
-
"""Parse one JSON object out of an LLM response (fences/prose tolerated).
|
|
453
|
-
import json as _json
|
|
454
|
-
import re as _re
|
|
508
|
+
"""Parse one JSON object out of an LLM response (fences/prose tolerated).
|
|
455
509
|
|
|
510
|
+
Recovery only — never invention. Every strategy here returns something the
|
|
511
|
+
model actually wrote; when none of them parse, this still raises and the
|
|
512
|
+
run fails loudly with the raw output preserved, exactly as before. The
|
|
513
|
+
point is that a 1–4B model's plan should not be discarded over a trailing
|
|
514
|
+
comma or a farewell sentence.
|
|
515
|
+
"""
|
|
456
516
|
text = str(raw or "").strip()
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
517
|
+
text = _THINK_BLOCK_RE.sub("", text)
|
|
518
|
+
text = _THINK_OPEN_RE.sub("", text).strip()
|
|
519
|
+
|
|
520
|
+
candidates: List[str] = []
|
|
521
|
+
for fenced in re.findall(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL):
|
|
522
|
+
candidates.append(fenced)
|
|
523
|
+
candidates.extend(_json_object_candidates(text))
|
|
524
|
+
if text.startswith("{"):
|
|
525
|
+
candidates.append(text)
|
|
526
|
+
|
|
527
|
+
last_error: Optional[Exception] = None
|
|
528
|
+
empty_fallback: Optional[Dict[str, Any]] = None
|
|
529
|
+
for candidate in candidates:
|
|
530
|
+
for attempt in (candidate, _TRAILING_COMMA_RE.sub(r"\1", candidate)):
|
|
531
|
+
try:
|
|
532
|
+
parsed = json.loads(attempt)
|
|
533
|
+
except ValueError as exc:
|
|
534
|
+
last_error = exc
|
|
535
|
+
continue
|
|
536
|
+
if not isinstance(parsed, dict):
|
|
537
|
+
last_error = ValueError("model returned JSON that is not an object")
|
|
538
|
+
continue
|
|
539
|
+
if parsed:
|
|
540
|
+
return parsed
|
|
541
|
+
# A prose aside like "I considered using { } notation" is a
|
|
542
|
+
# syntactically valid empty object, and taking the first thing that
|
|
543
|
+
# parses handed that back as the plan. An empty object is only ever
|
|
544
|
+
# the answer when nothing else in the reply is one.
|
|
545
|
+
empty_fallback = parsed
|
|
546
|
+
if empty_fallback is not None:
|
|
547
|
+
return empty_fallback
|
|
548
|
+
raise last_error or ValueError("model returned no JSON object")
|
|
468
549
|
|
|
469
550
|
|
|
470
551
|
def llm_role_runner(
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/admin.py
CHANGED
|
@@ -5,9 +5,10 @@ from collections import defaultdict
|
|
|
5
5
|
from datetime import datetime, timedelta
|
|
6
6
|
from typing import Any, Callable, Dict, List, Optional
|
|
7
7
|
|
|
8
|
-
from fastapi import APIRouter,
|
|
8
|
+
from fastapi import APIRouter, Query, Request
|
|
9
9
|
from pydantic import BaseModel
|
|
10
10
|
|
|
11
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
11
12
|
from latticeai.core.workspace_os import DEFAULT_WORKSPACE_ID
|
|
12
13
|
|
|
13
14
|
|
|
@@ -159,6 +160,78 @@ def create_admin_router(
|
|
|
159
160
|
"last_message_at": history[-1].get("timestamp") if history else None,
|
|
160
161
|
}
|
|
161
162
|
|
|
163
|
+
@router.get("/admin/health-summary")
|
|
164
|
+
async def admin_health_summary(request: Request):
|
|
165
|
+
"""One-line admin health for the calm console header (layout rebuild).
|
|
166
|
+
|
|
167
|
+
Aggregates existing admin surfaces — no new persistence. ``status`` is
|
|
168
|
+
``attention`` when any issue is present, else ``ok``.
|
|
169
|
+
"""
|
|
170
|
+
_, users = require_admin(request)
|
|
171
|
+
issues: List[Dict[str, Any]] = []
|
|
172
|
+
|
|
173
|
+
disabled = sum(1 for user in users.values() if user.get("disabled"))
|
|
174
|
+
if disabled:
|
|
175
|
+
issues.append({
|
|
176
|
+
"area": "users",
|
|
177
|
+
"severity": "warning",
|
|
178
|
+
"message": f"{disabled} disabled user(s)",
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
report = build_sensitivity_report(_scoped_history(request)) or {}
|
|
183
|
+
severity = ((report.get("summary") or {}).get("severity_counts") or {})
|
|
184
|
+
high = int(severity.get("high") or 0)
|
|
185
|
+
if high:
|
|
186
|
+
issues.append({
|
|
187
|
+
"area": "security",
|
|
188
|
+
"severity": "high",
|
|
189
|
+
"message": f"{high} high-risk event(s)",
|
|
190
|
+
})
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
logging.warning("admin health-summary sensitivity failed: %s", exc)
|
|
193
|
+
|
|
194
|
+
if enable_graph:
|
|
195
|
+
try:
|
|
196
|
+
stats = get_graph_stats() or {}
|
|
197
|
+
if isinstance(stats, dict) and stats.get("error"):
|
|
198
|
+
issues.append({
|
|
199
|
+
"area": "brain_ops",
|
|
200
|
+
"severity": "warning",
|
|
201
|
+
"message": "Knowledge graph unavailable",
|
|
202
|
+
})
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
issues.append({
|
|
205
|
+
"area": "brain_ops",
|
|
206
|
+
"severity": "warning",
|
|
207
|
+
"message": str(exc)[:160],
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
if product_hardening_status is not None:
|
|
211
|
+
try:
|
|
212
|
+
res = product_hardening_status()
|
|
213
|
+
hardening = res if isinstance(res, dict) else {}
|
|
214
|
+
startup = hardening.get("startup") if isinstance(hardening, dict) else {} # type: ignore[union-attr]
|
|
215
|
+
if isinstance(startup, dict) and startup.get("network_exposed"):
|
|
216
|
+
issues.append({
|
|
217
|
+
"area": "runtime_trust",
|
|
218
|
+
"severity": "warning",
|
|
219
|
+
"message": "Server is network-exposed",
|
|
220
|
+
})
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
logging.warning("admin health-summary hardening failed: %s", exc)
|
|
223
|
+
|
|
224
|
+
# issue_count is a first-class field (not client-derived from issues[]).
|
|
225
|
+
# AdminConsole reads healthData.issue_count for the header sentence;
|
|
226
|
+
# falling back to || 1 when the field is missing misreports multi-issue
|
|
227
|
+
# states as a single problem.
|
|
228
|
+
issue_count = len(issues)
|
|
229
|
+
return {
|
|
230
|
+
"status": "attention" if issue_count else "ok",
|
|
231
|
+
"issue_count": issue_count,
|
|
232
|
+
"issues": issues,
|
|
233
|
+
}
|
|
234
|
+
|
|
162
235
|
@router.get("/admin/stats")
|
|
163
236
|
async def admin_stats(request: Request):
|
|
164
237
|
require_admin(request)
|
|
@@ -319,17 +392,18 @@ def create_admin_router(
|
|
|
319
392
|
|
|
320
393
|
@router.patch("/admin/users/{email:path}")
|
|
321
394
|
async def admin_update_user(email: str, req: AdminUserUpdate, request: Request):
|
|
395
|
+
lang = resolve_language(request)
|
|
322
396
|
admin_email, users = require_admin(request)
|
|
323
397
|
if email not in users:
|
|
324
|
-
raise
|
|
398
|
+
raise http_error(404, "auth.user_not_found", lang)
|
|
325
399
|
before = public_user(email, users[email], users)
|
|
326
400
|
if req.role is not None:
|
|
327
401
|
if req.role not in {"admin", "user"}:
|
|
328
|
-
raise
|
|
402
|
+
raise http_error(400, "admin.invalid_role", lang)
|
|
329
403
|
users[email]["role"] = req.role
|
|
330
404
|
if req.disabled is not None:
|
|
331
405
|
if email == admin_email and req.disabled:
|
|
332
|
-
raise
|
|
406
|
+
raise http_error(400, "admin.cannot_disable_self", lang)
|
|
333
407
|
users[email]["disabled"] = req.disabled
|
|
334
408
|
save_users(users)
|
|
335
409
|
after = public_user(email, users[email], users)
|
|
@@ -338,11 +412,12 @@ def create_admin_router(
|
|
|
338
412
|
|
|
339
413
|
@router.delete("/admin/users/{email:path}")
|
|
340
414
|
async def admin_delete_user(email: str, request: Request):
|
|
415
|
+
lang = resolve_language(request)
|
|
341
416
|
admin_email, users = require_admin(request)
|
|
342
417
|
if email == admin_email:
|
|
343
|
-
raise
|
|
418
|
+
raise http_error(400, "admin.cannot_delete_self", lang)
|
|
344
419
|
if email not in users:
|
|
345
|
-
raise
|
|
420
|
+
raise http_error(404, "auth.user_not_found", lang)
|
|
346
421
|
deleted = public_user(email, users[email], users)
|
|
347
422
|
append_audit_event("user_delete", user_email=admin_email, target_email=email, deleted_user=deleted)
|
|
348
423
|
del users[email]
|