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.
Files changed (95) hide show
  1. package/README.md +56 -59
  2. package/docs/CHANGELOG.md +68 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/LAYOUT_REBUILD_SPEC.md +810 -0
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +29 -1
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/retrieval_vector.py +69 -33
  13. package/lattice_brain/runtime/multi_agent.py +96 -15
  14. package/latticeai/__init__.py +1 -1
  15. package/latticeai/api/admin.py +81 -6
  16. package/latticeai/api/auth.py +37 -38
  17. package/latticeai/api/automation_intelligence.py +112 -0
  18. package/latticeai/api/browser.py +7 -6
  19. package/latticeai/api/knowledge_graph.py +133 -0
  20. package/latticeai/api/static_routes.py +35 -1
  21. package/latticeai/core/file_generation.py +141 -8
  22. package/latticeai/core/legacy_compatibility.py +1 -1
  23. package/latticeai/core/marketplace.py +1 -1
  24. package/latticeai/core/messages.py +230 -0
  25. package/latticeai/core/workspace_computer_memory.py +84 -0
  26. package/latticeai/core/workspace_indexing.py +102 -0
  27. package/latticeai/core/workspace_onboarding.py +104 -0
  28. package/latticeai/core/workspace_os.py +27 -207
  29. package/latticeai/core/workspace_os_constants.py +1 -1
  30. package/latticeai/core/workspace_relationships.py +99 -0
  31. package/latticeai/core/workspace_runs.py +111 -0
  32. package/latticeai/services/architecture_readiness.py +1 -1
  33. package/latticeai/services/product_readiness.py +1 -1
  34. package/package.json +5 -3
  35. package/scripts/capture_release_evidence.mjs +335 -1
  36. package/scripts/check_current_release_docs.mjs +1 -1
  37. package/scripts/check_release_evidence_bound.mjs +116 -0
  38. package/scripts/check_screenshot_pixel_delta.py +517 -0
  39. package/scripts/measure_brain_home_fill.mjs +141 -0
  40. package/scripts/release_screen_claims.json +37 -0
  41. package/src-tauri/Cargo.lock +1 -1
  42. package/src-tauri/Cargo.toml +1 -1
  43. package/src-tauri/tauri.conf.json +1 -1
  44. package/static/app/asset-manifest.json +37 -39
  45. package/static/app/assets/{Act-05uZSgyw.js → Act-CbQNsFgc.js} +1 -1
  46. package/static/app/assets/AdminConsole-Bs8SR8fE.js +1 -0
  47. package/static/app/assets/{Brain-jjMYTdnU.js → Brain-BZIPsZlH.js} +2 -2
  48. package/static/app/assets/BrainHome-BaH5MDce.js +2 -0
  49. package/static/app/assets/{BrainSignals-BYxDqBH3.js → BrainSignals-aDt-nSsW.js} +1 -1
  50. package/static/app/assets/Capture-DDv940Bv.js +1 -0
  51. package/static/app/assets/{CommandPalette-Cj7z-qUE.js → CommandPalette-Dq_MmPBl.js} +1 -1
  52. package/static/app/assets/Library-BkShbqmN.js +1 -0
  53. package/static/app/assets/{LivingBrain-CTokkZn1.js → LivingBrain-CXb8094H.js} +1 -1
  54. package/static/app/assets/ProductFlow-BaiqFWLO.js +1 -0
  55. package/static/app/assets/{ReviewCard-uAII_l9v.js → ReviewCard-Be7E7X18.js} +2 -2
  56. package/static/app/assets/System-DCstbTxC.js +1 -0
  57. package/static/app/assets/arrow-left-DC9Oquhs.js +1 -0
  58. package/static/app/assets/{bot-CkrHFdUl.js → bot-BR0eoeXY.js} +1 -1
  59. package/static/app/assets/brain-BmWa1wea.js +1 -0
  60. package/static/app/assets/{button-Ic6omIbs.js → button-U1nU7xX4.js} +1 -1
  61. package/static/app/assets/{circle-pause-DBaaUR08.js → circle-pause-DG5KHjDL.js} +1 -1
  62. package/static/app/assets/{circle-play-Dz0OLYN9.js → circle-play-Chhcuq90.js} +1 -1
  63. package/static/app/assets/{cpu-N8uE9ono.js → cpu-pkOSlT9h.js} +1 -1
  64. package/static/app/assets/{download-CCjhOjms.js → download-CSRwz923.js} +1 -1
  65. package/static/app/assets/{folder-open-Bld9TtHp.js → folder-open-DiMJ7W_7.js} +1 -1
  66. package/static/app/assets/{hard-drive-BMIqDSyY.js → hard-drive-iyLPAPdp.js} +1 -1
  67. package/static/app/assets/index-BZRvGOM2.css +2 -0
  68. package/static/app/assets/{index-yNtiRyw3.js → index-_ldE9TAX.js} +4 -4
  69. package/static/app/assets/{input-DW1tXtc0.js → input-e_r2hFAY.js} +1 -1
  70. package/static/app/assets/{permissionCopy-Djpwn54H.js → permissionCopy-9uOHktz3.js} +1 -1
  71. package/static/app/assets/{primitives-B6WtkLgB.js → primitives-CnEAOmg2.js} +1 -1
  72. package/static/app/assets/search-veep4n6f.js +1 -0
  73. package/static/app/assets/{share-2-DR5L1qRc.js → share-2-DLDJJKmI.js} +1 -1
  74. package/static/app/assets/{shield-alert-CVE5eXsl.js → shield-alert-BTtE-2EZ.js} +1 -1
  75. package/static/app/assets/{textarea-DdaVFgvk.js → textarea-Bbzkcgqe.js} +1 -1
  76. package/static/app/assets/{useFocusTrap-B5TyGaiB.js → useFocusTrap-DJzQd-Za.js} +1 -1
  77. package/static/app/assets/{useQuery-CiRdTQlD.js → useQuery-6R_tqHRe.js} +1 -1
  78. package/static/app/assets/utils-XDsjcrhS.js +4 -0
  79. package/static/app/assets/workspace-CwrG8LHq.js +1 -0
  80. package/static/app/index.html +4 -4
  81. package/static/sw.js +1 -1
  82. package/static/app/assets/AdminConsole-C8yz1Cs_.js +0 -1
  83. package/static/app/assets/BrainHome-D3t45_D4.js +0 -2
  84. package/static/app/assets/Capture-DKgu7fB0.js +0 -1
  85. package/static/app/assets/Library-CO76FWeO.js +0 -1
  86. package/static/app/assets/ProductFlow-Bmted2ys.js +0 -1
  87. package/static/app/assets/System-PUMzDmof.js +0 -1
  88. package/static/app/assets/activity-C_7182GG.js +0 -1
  89. package/static/app/assets/arrow-left-BQCpWR37.js +0 -1
  90. package/static/app/assets/brain-CdPX7svt.js +0 -1
  91. package/static/app/assets/index-DYUs0cWy.css +0 -2
  92. package/static/app/assets/search-CkHYuKpY.js +0 -1
  93. package/static/app/assets/users-pZqCRxdD.js +0 -1
  94. package/static/app/assets/utils-C3rXoa-U.js +0 -4
  95. package/static/app/assets/workspace-DfxKEttN.js +0 -1
@@ -9,10 +9,11 @@ from dataclasses import dataclass
9
9
  from typing import Any, Awaitable, Callable, Dict, Optional
10
10
  from urllib.parse import urlencode
11
11
 
12
- from fastapi import APIRouter, HTTPException, Request
12
+ from fastapi import APIRouter, Request
13
13
  from fastapi.responses import JSONResponse, RedirectResponse
14
14
  from pydantic import BaseModel
15
15
 
16
+ from latticeai.core.messages import DEFAULT_LANGUAGE, http_error, resolve_language
16
17
  from latticeai.core.oidc import (
17
18
  OIDCValidationError,
18
19
  )
@@ -92,18 +93,16 @@ def create_auth_router(
92
93
  ) -> APIRouter:
93
94
  router = APIRouter()
94
95
 
95
- def _enforce_password_policy(password: str) -> None:
96
+ def _enforce_password_policy(password: str, language: str = DEFAULT_LANGUAGE) -> None:
96
97
  # Real policy (v4): length >= 8 with letters AND digits. A 4-char
97
98
  # minimum was not a policy.
98
99
  pw = str(password or "")
99
100
  if len(pw) < 8 or not any(c.isalpha() for c in pw) or not any(c.isdigit() for c in pw):
100
- raise HTTPException(
101
- status_code=400,
102
- detail="비밀번호는 8자 이상이며 영문자와 숫자를 모두 포함해야 합니다.",
103
- )
101
+ raise http_error(400, "auth.password_too_weak", language)
104
102
 
105
103
  @router.post("/register")
106
104
  async def register(req: UserRegister, request: Request):
105
+ lang = resolve_language(request)
107
106
  check_ip_rate_limit(client_ip(request), "register", max_calls=5, window_secs=3600)
108
107
  invite_claim = bool(
109
108
  invite_gate_enabled
@@ -111,19 +110,16 @@ def create_auth_router(
111
110
  and invite_authorized(request)
112
111
  )
113
112
  if invite_gate_enabled and not invite_claim:
114
- raise HTTPException(
115
- status_code=403,
116
- detail="유효한 서명 초대 권한이 필요합니다.",
117
- )
113
+ raise http_error(403, "auth.invitation_required", lang)
118
114
  # Closed public registration stays closed unless the operator enabled
119
115
  # the invite gate and this exact request carries a valid signed claim.
120
116
  if not open_registration and not invite_claim:
121
- raise HTTPException(status_code=403, detail="회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.")
122
- _enforce_password_policy(req.password)
117
+ raise http_error(403, "auth.registration_disabled", lang)
118
+ _enforce_password_policy(req.password, lang)
123
119
  email = normalize_email(req.email)
124
120
  users = load_users()
125
121
  if email in users:
126
- raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다.")
122
+ raise http_error(400, "auth.email_taken", lang)
127
123
  role = "admin" if not users else "user"
128
124
  users[email] = {
129
125
  "password": hash_password(req.password),
@@ -140,14 +136,15 @@ def create_auth_router(
140
136
 
141
137
  @router.post("/login")
142
138
  async def login(req: UserLogin, request: Request):
139
+ lang = resolve_language(request)
143
140
  check_ip_rate_limit(client_ip(request), "login", max_calls=10, window_secs=300)
144
141
  email = normalize_email(req.email)
145
142
  users = load_users()
146
143
  user = users.get(email)
147
144
  if not user or not verify_and_migrate(email, req.password, user.get("password", ""), users):
148
- raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 틀렸습니다.")
145
+ raise http_error(401, "auth.bad_credentials", lang)
149
146
  if user.get("disabled"):
150
- raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
147
+ raise http_error(403, "auth.account_disabled", lang)
151
148
  role = get_user_role(email, users)
152
149
  token = create_session(email)
153
150
  response = JSONResponse(content={
@@ -174,10 +171,11 @@ def create_auth_router(
174
171
 
175
172
  @router.get("/auth/sso/login")
176
173
  async def sso_login(request: Request):
174
+ lang = resolve_language(request)
177
175
  settings = get_sso_settings()
178
176
  discovery = await get_sso_discovery()
179
177
  if not settings.get("enabled") or not discovery:
180
- raise HTTPException(status_code=503, detail="SSO가 설정되지 않았습니다.")
178
+ raise http_error(503, "sso.not_configured", lang)
181
179
  state = secrets.token_urlsafe(16)
182
180
  nonce = secrets.token_urlsafe(16)
183
181
  # PKCE (S256): bind the token exchange to this login, so an
@@ -214,16 +212,17 @@ def create_auth_router(
214
212
  return RedirectResponse(f"{discovery['authorization_endpoint']}?{params}")
215
213
 
216
214
  @router.get("/auth/sso/callback")
217
- async def sso_callback(code: str = "", state: str = "", error: str = ""):
215
+ async def sso_callback(request: Request, code: str = "", state: str = "", error: str = ""):
216
+ lang = resolve_language(request)
218
217
  entry = _sso_states.pop(state, None)
219
218
  if entry is None or time.time() - entry.issued_at > 300:
220
- raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
219
+ raise http_error(400, "sso.invalid_state", lang)
221
220
  if error:
222
221
  return RedirectResponse(f"/?{urlencode({'sso_error': error})}")
223
222
  settings = get_sso_settings()
224
223
  discovery = await get_sso_discovery()
225
224
  if not settings.get("enabled") or not discovery:
226
- raise HTTPException(status_code=503, detail="SSO 설정 오류입니다.")
225
+ raise http_error(503, "sso.config_error", lang)
227
226
  import httpx as _httpx
228
227
  async with _httpx.AsyncClient() as c:
229
228
  r = await c.post(discovery["token_endpoint"], data={
@@ -237,7 +236,7 @@ def create_auth_router(
237
236
  tokens = r.json()
238
237
  id_token = tokens.get("id_token")
239
238
  if not id_token:
240
- raise HTTPException(status_code=400, detail="ID 토큰을 받지 못했습니다.")
239
+ raise http_error(400, "sso.no_id_token", lang)
241
240
  # Never trust a decoded JWT payload: verify signature (against the
242
241
  # provider JWKS), issuer, audience, expiry and the login nonce before
243
242
  # using any claim. Any failure is fail-closed (401).
@@ -253,20 +252,17 @@ def create_auth_router(
253
252
  )
254
253
  except OIDCValidationError as exc:
255
254
  logging.warning("SSO ID token rejected: %s", exc)
256
- raise HTTPException(status_code=401, detail="SSO 토큰 검증에 실패했습니다.")
255
+ raise http_error(401, "sso.token_verification_failed", lang)
257
256
  except Exception as exc: # discovery/JWKS fetch failure → fail closed
258
257
  logging.warning("SSO token validation error: %s", exc)
259
- raise HTTPException(status_code=502, detail="SSO 공급자 검증에 실패했습니다.")
258
+ raise http_error(502, "sso.provider_verification_failed", lang)
260
259
  email = normalize_email(payload.get("email") or payload.get("preferred_username") or payload.get("upn") or "")
261
260
  if not email:
262
- raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
261
+ raise http_error(400, "sso.email_unavailable", lang)
263
262
  users = load_users()
264
263
  if email not in users:
265
264
  if invite_gate_enabled and not entry.invite_authorized:
266
- raise HTTPException(
267
- status_code=403,
268
- detail="신규 SSO 계정에는 유효한 서명 초대 권한이 필요합니다.",
269
- )
265
+ raise http_error(403, "sso.invitation_required", lang)
270
266
  is_first = len(users) == 0
271
267
  users[email] = {
272
268
  "password": "",
@@ -280,7 +276,7 @@ def create_auth_router(
280
276
  ensure_identity(email, users[email])
281
277
  save_users(users)
282
278
  if users[email].get("disabled"):
283
- raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
279
+ raise http_error(403, "auth.account_disabled", lang)
284
280
  token = create_session(email)
285
281
  resp = RedirectResponse("/app", status_code=302)
286
282
  resp.set_cookie(
@@ -309,33 +305,35 @@ def create_auth_router(
309
305
 
310
306
  @router.post("/account/change-password")
311
307
  async def change_password(req: ChangePasswordRequest, request: Request):
308
+ lang = resolve_language(request)
312
309
  email = normalize_email(require_user(request))
313
310
  if not email:
314
- raise HTTPException(status_code=401, detail="인증이 필요합니다.")
315
- _enforce_password_policy(req.new_password)
311
+ raise http_error(401, "auth.login_required", lang)
312
+ _enforce_password_policy(req.new_password, lang)
316
313
  users = load_users()
317
314
  user = users.get(email)
318
315
  if not user:
319
- raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
316
+ raise http_error(404, "auth.user_not_found", lang)
320
317
  if not verify_and_migrate(email, req.current_password, user.get("password", ""), users):
321
- raise HTTPException(status_code=401, detail="현재 비밀번호가 틀렸습니다.")
318
+ raise http_error(401, "auth.current_password_wrong", lang)
322
319
  users[email]["password"] = hash_password(req.new_password)
323
320
  save_users(users)
324
321
  return {"status": "ok", "message": "비밀번호가 변경되었습니다."}
325
322
 
326
323
  @router.patch("/account/profile")
327
324
  async def update_profile(req: UpdateProfileRequest, request: Request):
325
+ lang = resolve_language(request)
328
326
  email = normalize_email(require_user(request))
329
327
  if not email:
330
- raise HTTPException(status_code=401, detail="인증이 필요합니다.")
328
+ raise http_error(401, "auth.login_required", lang)
331
329
  if req.name is not None and not req.name.strip():
332
- raise HTTPException(status_code=400, detail="이름을 입력해주세요.")
330
+ raise http_error(400, "auth.name_required", lang)
333
331
  if req.nickname is not None and not req.nickname.strip():
334
- raise HTTPException(status_code=400, detail="닉네임을 입력해주세요.")
332
+ raise http_error(400, "auth.nickname_required", lang)
335
333
  users = load_users()
336
334
  user = users.get(email)
337
335
  if not user:
338
- raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
336
+ raise http_error(404, "auth.user_not_found", lang)
339
337
  if req.name is not None:
340
338
  users[email]["name"] = req.name.strip()
341
339
  if req.nickname is not None:
@@ -345,15 +343,16 @@ def create_auth_router(
345
343
 
346
344
  @router.get("/account/profile")
347
345
  async def get_profile(request: Request):
346
+ lang = resolve_language(request)
348
347
  email = normalize_email(require_user(request))
349
348
  if not email:
350
349
  if require_auth:
351
- raise HTTPException(status_code=401, detail="인증이 필요합니다.")
350
+ raise http_error(401, "auth.login_required", lang)
352
351
  return {"email": "", "name": "Local User", "nickname": "You", "role": "admin", "is_admin": True}
353
352
  users = load_users()
354
353
  user = users.get(email)
355
354
  if not user:
356
- raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
355
+ raise http_error(404, "auth.user_not_found", lang)
357
356
  role = get_user_role(email, users)
358
357
  return {"email": email, "name": user.get("name", ""), "nickname": user.get("nickname", ""),
359
358
  "role": role, "is_admin": role == "admin"}
@@ -44,6 +44,65 @@ class AutomationRunNowRequest(BaseModel):
44
44
  dry_run: bool = True
45
45
 
46
46
 
47
+ def _ensure_activity_run_source(run: Any) -> dict[str, Any]:
48
+ """Guarantee the Act feed ``source`` field for 레시피/목표 badge split.
49
+
50
+ Capture 09 (``frontend/src/pages/Act.tsx``) distinguishes workflow vs agent
51
+ with ``run.source === "workflow" || Boolean(run.workflow_id)``. A store that
52
+ omits both collapses every row to "목표" and silently undoes the merged
53
+ timeline redesign. Prefer an explicit ``source``; otherwise infer from
54
+ ``workflow_id`` / ``agent_id``.
55
+ """
56
+ if not isinstance(run, dict):
57
+ return {"source": "agent", "id": None, "title": "", "status": ""}
58
+ row = dict(run)
59
+ source = str(row.get("source") or "").strip().lower()
60
+ if source in {"workflow", "agent"}:
61
+ row["source"] = source
62
+ return row
63
+ if row.get("workflow_id"):
64
+ row["source"] = "workflow"
65
+ return row
66
+ if row.get("agent_id"):
67
+ row["source"] = "agent"
68
+ return row
69
+ # Last resort: keep any non-empty legacy value, else agent.
70
+ row["source"] = source if source else "agent"
71
+ return row
72
+
73
+
74
+ def _with_run_truncation_meta(payload: Any, *, capped: int) -> dict[str, Any]:
75
+ """Guarantee ``total`` / ``truncated`` on combined-run payloads.
76
+
77
+ Stores that already implement :meth:`list_combined_runs` may return only
78
+ ``runs``. The Act feed needs to say how many rows are hidden when the
79
+ server slices agent+workflow history (default cap 20).
80
+
81
+ Every run is also passed through :func:`_ensure_activity_run_source` so the
82
+ 레시피/목표 badge never loses its discriminator.
83
+ """
84
+ if not isinstance(payload, dict):
85
+ return {"runs": [], "total": 0, "truncated": False}
86
+ runs = [_ensure_activity_run_source(run) for run in (payload.get("runs") or [])]
87
+ if "total" in payload:
88
+ try:
89
+ total = max(0, int(payload.get("total") or 0))
90
+ except (TypeError, ValueError):
91
+ total = len(runs)
92
+ else:
93
+ total = len(runs)
94
+ if "truncated" in payload:
95
+ truncated = bool(payload.get("truncated"))
96
+ else:
97
+ truncated = total > len(runs) or total > capped
98
+ # Prefer the larger of declared total and visible rows so a buggy store
99
+ # never claims fewer total than the list it just returned.
100
+ total = max(total, len(runs))
101
+ if total > len(runs):
102
+ truncated = True
103
+ return {**payload, "runs": runs, "total": total, "truncated": truncated}
104
+
105
+
47
106
  def _run_now_wait_seconds() -> float:
48
107
  raw = os.environ.get("LATTICEAI_AUTOMATION_RUN_NOW_WAIT", "30")
49
108
  try:
@@ -283,6 +342,59 @@ def create_automation_intelligence_router(
283
342
  payload["review_item_id"] = review_item.get("id")
284
343
  return payload
285
344
 
345
+ def _combined_runs(request: Request, limit: int = 20) -> dict[str, Any]:
346
+ require_user(request)
347
+ scope = gate_read(request)
348
+ capped = max(1, min(int(limit or 20), 100))
349
+ if hasattr(store, "list_combined_runs"):
350
+ payload = store.list_combined_runs(limit=capped, workspace_id=scope)
351
+ return _with_run_truncation_meta(payload, capped=capped)
352
+ # Store without the helper: merge the two existing listings in place.
353
+ agent_listing = (
354
+ store.list_agents(workspace_id=scope)
355
+ if hasattr(store, "list_agents")
356
+ else {"runs": []}
357
+ )
358
+ # Pull a wide window so agent+workflow merge can still fill ``capped``
359
+ # after sort; true total is the unscoped merge size before slice.
360
+ workflow_listing = (
361
+ store.list_workflow_runs(limit=max(capped, 100), workspace_id=scope)
362
+ if hasattr(store, "list_workflow_runs")
363
+ else {"runs": []}
364
+ )
365
+ from latticeai.core.workspace_runs import WorkspaceRuns
366
+
367
+ rows = []
368
+ for run in agent_listing.get("runs") or []:
369
+ rows.append(
370
+ _ensure_activity_run_source(
371
+ WorkspaceRuns.activity_run_row(run, source="agent")
372
+ )
373
+ )
374
+ for run in workflow_listing.get("runs") or []:
375
+ rows.append(
376
+ _ensure_activity_run_source(
377
+ WorkspaceRuns.activity_run_row(run, source="workflow")
378
+ )
379
+ )
380
+ rows.sort(key=lambda row: str(row.get("started_at") or ""), reverse=True)
381
+ total = len(rows)
382
+ return {
383
+ "runs": rows[:capped],
384
+ "total": total,
385
+ "truncated": total > capped,
386
+ }
387
+
388
+ @router.get("/api/activity/runs")
389
+ async def activity_runs(request: Request, limit: int = 20):
390
+ """Unified agent + workflow run timeline (layout rebuild screen 09)."""
391
+ return _combined_runs(request, limit=limit)
392
+
393
+ @router.get("/automations/runs/combined")
394
+ async def automations_runs_combined(request: Request, limit: int = 20):
395
+ """Alias used by the frontend handoff for the same combined timeline."""
396
+ return _combined_runs(request, limit=limit)
397
+
286
398
  return router
287
399
 
288
400
 
@@ -29,6 +29,7 @@ from pydantic import BaseModel
29
29
  from lattice_brain.ingestion import IngestionItem, capture_quality_verdict
30
30
  from latticeai import __version__
31
31
  from latticeai.api.workspace_scope import resolve_workspace_scope
32
+ from latticeai.core.messages import DEFAULT_LANGUAGE, http_error, resolve_language
32
33
  from latticeai.core.quiet import quiet
33
34
 
34
35
  MAX_TAB_BYTES = 4 * 1024 * 1024 # 4 MB per captured tab payload
@@ -395,9 +396,9 @@ def create_browser_router(
395
396
  router = APIRouter()
396
397
  _fetch = fetch_url or _default_fetch_url
397
398
 
398
- def _require_pipeline():
399
+ def _require_pipeline(language: str = DEFAULT_LANGUAGE):
399
400
  if pipeline is None or not pipeline.available():
400
- raise HTTPException(status_code=503, detail="Knowledge Graph ingestion is disabled.")
401
+ raise http_error(503, "capture.ingestion_disabled", language)
401
402
 
402
403
  def _write_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
403
404
  return resolve_workspace_scope(
@@ -412,7 +413,7 @@ def create_browser_router(
412
413
  async def read_url(req: ReadUrlRequest, request: Request):
413
414
  """Fetch a public URL locally and ingest it as a web_url source."""
414
415
  user = require_user(request)
415
- _require_pipeline()
416
+ _require_pipeline(resolve_language(request))
416
417
  workspace_id = _write_workspace(request, req.workspace_id, user)
417
418
  url = _validate_http_url(req.url)
418
419
  try:
@@ -443,7 +444,7 @@ def create_browser_router(
443
444
  async def ingest_current_tab(req: IngestTabRequest, request: Request):
444
445
  """Ingest a payload captured from the local browser extension."""
445
446
  user = require_user(request)
446
- _require_pipeline()
447
+ _require_pipeline(resolve_language(request))
447
448
  workspace_id = _write_workspace(request, req.workspace_id, user)
448
449
  url = _validate_http_url(req.url)
449
450
  # Bound the entire capture, not merely each field independently. The
@@ -462,14 +463,14 @@ def create_browser_router(
462
463
  if value
463
464
  )
464
465
  if captured_bytes > max_tab_bytes:
465
- raise HTTPException(status_code=413, detail="Captured payload is too large.")
466
+ raise http_error(413, "capture.payload_too_large", resolve_language(request))
466
467
  text = (req.text or "").strip()
467
468
  if not text and req.html:
468
469
  _title, text = extract_readable_text(req.html)
469
470
  if not text:
470
471
  text = (req.selected_text or "").strip()
471
472
  if not text:
472
- raise HTTPException(status_code=400, detail="No text, html, or selected_text provided.")
473
+ raise http_error(400, "capture.nothing_to_capture", resolve_language(request))
473
474
  res = pipeline.ingest(
474
475
  IngestionItem(
475
476
  source_type="browser_tab",
@@ -6,6 +6,7 @@ remains as a deprecation shim. Route paths and response shapes are frozen by
6
6
  data endpoints back the v3 SPA (Files, Hybrid Search, graph explorer).
7
7
  """
8
8
 
9
+ from datetime import datetime
9
10
  from pathlib import Path
10
11
  from typing import Any, Callable, Dict, List, Optional
11
12
 
@@ -18,6 +19,7 @@ from latticeai.api.workspace_scope import (
18
19
  resolve_workspace_scope,
19
20
  workspace_scope_from_request,
20
21
  )
22
+ from latticeai.core.quiet import quiet
21
23
 
22
24
 
23
25
  class KnowledgeGraphIngestRequest(BaseModel):
@@ -71,6 +73,25 @@ def _format_context(matches: list, limit: int) -> str:
71
73
  return "\n".join(lines)
72
74
 
73
75
 
76
+ def _pipeline_stage_view(*, count: int, pending: int) -> Dict[str, Any]:
77
+ """Derive a coherent journey-ribbon stage from count + pending.
78
+
79
+ Invariants (enforced for layout-rebuild Capture screen 11):
80
+ - ``pending > 0`` → ``working``
81
+ - ``pending == 0`` and ``count > 0`` → ``done`` (never ``waiting``)
82
+ - ``pending == 0`` and ``count == 0`` → ``waiting`` (nothing arrived yet)
83
+ """
84
+ safe_count = max(0, int(count))
85
+ safe_pending = max(0, int(pending))
86
+ if safe_pending > 0:
87
+ status = "working"
88
+ elif safe_count > 0:
89
+ status = "done"
90
+ else:
91
+ status = "waiting"
92
+ return {"count": safe_count, "pending": safe_pending, "status": status}
93
+
94
+
74
95
  def create_knowledge_graph_router(
75
96
  *,
76
97
  get_graph: Callable[[], Any],
@@ -248,6 +269,118 @@ def create_knowledge_graph_router(
248
269
  async def knowledge_graph_stats(request: Request):
249
270
  return _scoped_stats(request)
250
271
 
272
+ @router.get("/knowledge-graph/pipeline/status")
273
+ async def knowledge_graph_pipeline_status(request: Request):
274
+ """Per-stage counts + status for the Capture 3-step journey ribbon.
275
+
276
+ Aggregates existing store data only — no schema change. Top-level
277
+ ``received`` / ``extracted`` / ``connected`` stay for simple clients;
278
+ ``stages`` is the single source of truth for count, pending, and
279
+ status (``done`` / ``working`` / ``waiting``). Keys are omitted when
280
+ a value cannot be computed (never faked as 0 when the underlying
281
+ source is unavailable).
282
+ """
283
+ kg, allowed = _scoped(request)
284
+ received: Optional[int] = None
285
+ extracted: Optional[int] = None
286
+ connected: Optional[int] = None
287
+ index_pending: Optional[int] = None
288
+
289
+ try:
290
+ docs_payload = kg.list_documents(10_000)
291
+ documents = list(docs_payload.get("documents") or [])
292
+ if allowed is not None:
293
+ documents = _filter_scoped(kg, documents, allowed)
294
+ received = len(documents)
295
+ extracted = sum(
296
+ 1
297
+ for doc in documents
298
+ if doc.get("indexed") or int(doc.get("chunks") or 0) > 0
299
+ )
300
+ except Exception:
301
+ quiet()
302
+
303
+ try:
304
+ stats = _scoped_stats(request)
305
+ edges = stats.get("edges")
306
+ if isinstance(edges, dict):
307
+ connected = sum(int(value or 0) for value in edges.values())
308
+ elif isinstance(edges, (int, float)) and not isinstance(edges, bool):
309
+ connected = int(edges)
310
+ elif isinstance(stats.get("v2"), dict):
311
+ v2_edges = stats["v2"].get("edges")
312
+ if isinstance(v2_edges, (int, float)) and not isinstance(v2_edges, bool):
313
+ connected = int(v2_edges)
314
+ if received is None:
315
+ nodes = stats.get("nodes")
316
+ if isinstance(nodes, dict):
317
+ received = sum(
318
+ int(value or 0)
319
+ for key, value in nodes.items()
320
+ if str(key).lower() != "chunk"
321
+ )
322
+ elif isinstance(nodes, (int, float)) and not isinstance(nodes, bool):
323
+ received = int(nodes)
324
+ except Exception:
325
+ quiet()
326
+
327
+ index_fn = getattr(kg, "index_status", None)
328
+ if callable(index_fn):
329
+ try:
330
+ index = index_fn() or {}
331
+ if received is None and index.get("source_items") is not None:
332
+ received = int(index.get("source_items") or 0)
333
+ if extracted is None and index.get("ready_items") is not None:
334
+ extracted = int(index.get("ready_items") or 0)
335
+ if index.get("pending_items") is not None:
336
+ index_pending = max(0, int(index.get("pending_items") or 0))
337
+ elif index.get("pending") is not None:
338
+ index_pending = max(0, int(index.get("pending") or 0))
339
+ except Exception:
340
+ quiet()
341
+
342
+ payload: Dict[str, Any] = {}
343
+ if received is not None:
344
+ payload["received"] = max(0, int(received))
345
+ if extracted is not None:
346
+ payload["extracted"] = max(0, int(extracted))
347
+ if connected is not None:
348
+ payload["connected"] = max(0, int(connected))
349
+
350
+ if payload:
351
+ stages: Dict[str, Dict[str, Any]] = {}
352
+ if "received" in payload:
353
+ # Once listed as a document it has been received — no backlog.
354
+ stages["received"] = _pipeline_stage_view(
355
+ count=payload["received"], pending=0
356
+ )
357
+ if "extracted" in payload:
358
+ # Prefer vector-index backlog; fall back to docs not yet chunked.
359
+ if index_pending is not None:
360
+ extract_pending = index_pending
361
+ elif "received" in payload:
362
+ extract_pending = max(
363
+ 0, int(payload["received"]) - int(payload["extracted"])
364
+ )
365
+ else:
366
+ extract_pending = 0
367
+ stages["extracted"] = _pipeline_stage_view(
368
+ count=payload["extracted"], pending=extract_pending
369
+ )
370
+ if "connected" in payload:
371
+ # Connections are graph edges; backlog only when index reports
372
+ # pending items that still need linking (reuse extract backlog
373
+ # only when we have no better signal — 0 when graph is ready).
374
+ connect_pending = 0
375
+ if index_pending is not None and payload["connected"] == 0:
376
+ connect_pending = index_pending
377
+ stages["connected"] = _pipeline_stage_view(
378
+ count=payload["connected"], pending=connect_pending
379
+ )
380
+ payload["stages"] = stages
381
+ payload["updated_at"] = datetime.now().isoformat(timespec="seconds")
382
+ return payload
383
+
251
384
  @router.get("/knowledge-graph/schema")
252
385
  async def knowledge_graph_schema(request: Request):
253
386
  stats = _scoped_stats(request)
@@ -34,6 +34,30 @@ PRODUCTION_CSP = (
34
34
  INVITE_COOKIE_NAME = "lattice_invite"
35
35
  INVITE_COOKIE_TTL_SECONDS = 60 * 60 * 24 * 7
36
36
 
37
+ # Host-capacity plain-language judgment for System basic mode (layout rebuild).
38
+ # Thresholds live here so the frontend never re-derives copy from raw percents.
39
+ SYSINFO_READINESS_ROOMY_MAX = 55.0
40
+ SYSINFO_READINESS_TIGHT_MAX = 80.0
41
+
42
+
43
+ def host_capacity_readiness(
44
+ *,
45
+ cpu_pct: float = 0.0,
46
+ ram_pct: float = 0.0,
47
+ gpu_mem_pct: float = 0.0,
48
+ ) -> str:
49
+ """Map host telemetry to a single plain-language readiness bucket.
50
+
51
+ Returns one of ``roomy`` / ``tight`` / ``low``. The product uses this so
52
+ basic-mode System copy and advanced-mode numbers share one judgment.
53
+ """
54
+ load = max(float(cpu_pct or 0.0), float(ram_pct or 0.0), float(gpu_mem_pct or 0.0))
55
+ if load <= SYSINFO_READINESS_ROOMY_MAX:
56
+ return "roomy"
57
+ if load <= SYSINFO_READINESS_TIGHT_MAX:
58
+ return "tight"
59
+ return "low"
60
+
37
61
 
38
62
  def _sign_invite_cookie(secret: str, *, now: Optional[int] = None) -> str:
39
63
  """Create an expiring, nonce-bearing invite-gate capability cookie."""
@@ -250,7 +274,11 @@ def create_static_routes_router(
250
274
 
251
275
  @api_router.get("/local/sysinfo")
252
276
  async def local_sysinfo(request: Request):
253
- """CPU / RAM / GPU(MLX) 사용량을 반환합니다."""
277
+ """CPU / RAM / GPU(MLX) usage plus a plain-language readiness bucket.
278
+
279
+ ``readiness`` is ``roomy`` | ``tight`` | ``low`` so basic System copy
280
+ does not re-interpret raw percents on the client.
281
+ """
254
282
  require_user(request)
255
283
  import re as _re
256
284
  result: Dict[str, Any] = {
@@ -258,6 +286,7 @@ def create_static_routes_router(
258
286
  "ram_pct": 0.0,
259
287
  "gpu_mem_pct": 0.0,
260
288
  "gpu_mem_gb": 0.0,
289
+ "readiness": "roomy",
261
290
  }
262
291
  try:
263
292
  # CPU
@@ -291,6 +320,11 @@ def create_static_routes_router(
291
320
  quiet()
292
321
  except Exception as e:
293
322
  result["error"] = str(e)
323
+ result["readiness"] = host_capacity_readiness(
324
+ cpu_pct=float(result.get("cpu_pct") or 0.0),
325
+ ram_pct=float(result.get("ram_pct") or 0.0),
326
+ gpu_mem_pct=float(result.get("gpu_mem_pct") or 0.0),
327
+ )
294
328
  return result
295
329
 
296
330
  return StaticRoutesBundle(