ltcai 10.7.0 → 10.9.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 (97) hide show
  1. package/README.md +55 -40
  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/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/retrieval_vector.py +69 -33
  12. package/lattice_brain/runtime/multi_agent.py +96 -15
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +9 -6
  15. package/latticeai/api/auth.py +37 -38
  16. package/latticeai/api/browser.py +7 -6
  17. package/latticeai/api/chat.py +9 -6
  18. package/latticeai/api/chat_agent_http.py +2 -1
  19. package/latticeai/api/chat_history.py +4 -2
  20. package/latticeai/api/chat_intents.py +14 -14
  21. package/latticeai/api/knowledge_graph.py +5 -4
  22. package/latticeai/api/local_files.py +25 -15
  23. package/latticeai/api/mcp.py +15 -11
  24. package/latticeai/api/memory.py +2 -1
  25. package/latticeai/api/models.py +39 -24
  26. package/latticeai/api/network_boundary.py +9 -4
  27. package/latticeai/api/portability.py +27 -19
  28. package/latticeai/api/project_sessions.py +7 -5
  29. package/latticeai/api/review_queue.py +12 -7
  30. package/latticeai/api/setup.py +10 -11
  31. package/latticeai/api/static_routes.py +62 -46
  32. package/latticeai/api/tools.py +7 -6
  33. package/latticeai/core/file_generation.py +141 -8
  34. package/latticeai/core/legacy_compatibility.py +1 -1
  35. package/latticeai/core/marketplace.py +1 -1
  36. package/latticeai/core/mcp_registry.py +20 -4
  37. package/latticeai/core/messages.py +452 -0
  38. package/latticeai/core/workspace_computer_memory.py +84 -0
  39. package/latticeai/core/workspace_indexing.py +102 -0
  40. package/latticeai/core/workspace_onboarding.py +104 -0
  41. package/latticeai/core/workspace_os.py +24 -207
  42. package/latticeai/core/workspace_os_constants.py +1 -1
  43. package/latticeai/core/workspace_relationships.py +99 -0
  44. package/latticeai/integrations/telegram_bot.py +20 -18
  45. package/latticeai/services/architecture_readiness.py +1 -1
  46. package/latticeai/services/model_loading.py +63 -27
  47. package/latticeai/services/product_readiness.py +1 -1
  48. package/package.json +3 -2
  49. package/scripts/check_current_release_docs.mjs +1 -1
  50. package/scripts/check_screenshot_pixel_delta.py +92 -10
  51. package/scripts/check_server_i18n.mjs +96 -0
  52. package/scripts/release_screen_claims.json +46 -0
  53. package/src-tauri/Cargo.lock +1 -1
  54. package/src-tauri/Cargo.toml +1 -1
  55. package/src-tauri/tauri.conf.json +1 -1
  56. package/static/app/asset-manifest.json +37 -37
  57. package/static/app/assets/{Act-DIbkoJqs.js → Act-CS9IeqUX.js} +1 -1
  58. package/static/app/assets/{AdminConsole-Cbi6Jre6.js → AdminConsole-3UkIEWGA.js} +1 -1
  59. package/static/app/assets/{Brain-BPfuXZ71.js → Brain-B22EmNqS.js} +1 -1
  60. package/static/app/assets/{BrainHome-87XaAS4V.js → BrainHome-CMDqgJF4.js} +2 -2
  61. package/static/app/assets/{BrainSignals-ExpcdiUi.js → BrainSignals-BeE8RJo3.js} +1 -1
  62. package/static/app/assets/{Capture-Bxe4WiVX.js → Capture-ZX9bQh68.js} +1 -1
  63. package/static/app/assets/{CommandPalette-B1PV5Yd4.js → CommandPalette-86m4FCcN.js} +1 -1
  64. package/static/app/assets/{Library-BRlJJmwZ.js → Library-Bhz5LUca.js} +1 -1
  65. package/static/app/assets/{LivingBrain-D61qG3WY.js → LivingBrain-BSa0wpFG.js} +1 -1
  66. package/static/app/assets/ProductFlow-IP4Q-5aQ.js +1 -0
  67. package/static/app/assets/{ReviewCard-BoiWv0Y1.js → ReviewCard-BepjSDpN.js} +1 -1
  68. package/static/app/assets/{System-Ci0HEtTJ.js → System-Bwx1h_jT.js} +1 -1
  69. package/static/app/assets/arrow-left-ig7AZU8B.js +1 -0
  70. package/static/app/assets/{bot-BaPt4PPn.js → bot-DQj0-LkM.js} +1 -1
  71. package/static/app/assets/{brain-BSkWaj3i.js → brain-D8OEwmVj.js} +1 -1
  72. package/static/app/assets/{button-CKwtO1O0.js → button-CmTknyAP.js} +1 -1
  73. package/static/app/assets/{circle-pause-CiUc5RRk.js → circle-pause-yTCWRziJ.js} +1 -1
  74. package/static/app/assets/{circle-play-BaTDxzR9.js → circle-play-Ccrva84R.js} +1 -1
  75. package/static/app/assets/{cpu-Dm1gHuP1.js → cpu-CbJqWTlS.js} +1 -1
  76. package/static/app/assets/{download-C32oGU-C.js → download-CkSzbzU-.js} +1 -1
  77. package/static/app/assets/{folder-open-Ch2jLVdD.js → folder-open-CKyjQ4PU.js} +1 -1
  78. package/static/app/assets/{hard-drive-BBjA5st8.js → hard-drive-DAzk9um0.js} +1 -1
  79. package/static/app/assets/index-BfD-jhA9.css +2 -0
  80. package/static/app/assets/{index-J6h01X78.js → index-CxOcwsHV.js} +3 -3
  81. package/static/app/assets/{input-y6ABdKWY.js → input-DcMETmZ7.js} +1 -1
  82. package/static/app/assets/{permissionCopy-BEtc0Ihd.js → permissionCopy-BVf13_25.js} +1 -1
  83. package/static/app/assets/{primitives-GSFXF2Pd.js → primitives-CQV9Q2YM.js} +1 -1
  84. package/static/app/assets/search-CXGASMMH.js +1 -0
  85. package/static/app/assets/{share-2-BgGq2A63.js → share-2-COWCHNZm.js} +1 -1
  86. package/static/app/assets/{shield-alert-BYA491cj.js → shield-alert-BDrvilyK.js} +1 -1
  87. package/static/app/assets/{textarea-BAcim3fs.js → textarea-rUmsc8cP.js} +1 -1
  88. package/static/app/assets/{useFocusTrap-qLg24jiv.js → useFocusTrap-Bi5UY_8v.js} +1 -1
  89. package/static/app/assets/{useQuery-xkAu9eDX.js → useQuery-C-AicB-3.js} +1 -1
  90. package/static/app/assets/{utils-CogdofAA.js → utils-BMwWg78e.js} +1 -1
  91. package/static/app/assets/{workspace-BMvfx9_R.js → workspace-Y93tls8P.js} +1 -1
  92. package/static/app/index.html +4 -4
  93. package/static/sw.js +1 -1
  94. package/static/app/assets/ProductFlow-BxjxI8Aa.js +0 -1
  95. package/static/app/assets/arrow-left-DArdBUxd.js +0 -1
  96. package/static/app/assets/index-BV7Yf6Eq.css +0 -2
  97. package/static/app/assets/search-CaIZKyo_.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"}
@@ -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",
@@ -51,6 +51,7 @@ from latticeai.api.chat_hybrid import (
51
51
  )
52
52
  from latticeai.api.chat_intents import ChatIntentController
53
53
  from latticeai.api.chat_stream import stream_chat
54
+ from latticeai.core.messages import DEFAULT_LANGUAGE, resolve_language, translate
54
55
  from latticeai.core.project_sessions import ProjectSessionStore
55
56
  from latticeai.core.run_store import AgentRunStore
56
57
  from latticeai.services.app_context import AppContext
@@ -122,12 +123,13 @@ def create_chat_router(context: AppContext) -> APIRouter:
122
123
  def authenticated_identity(
123
124
  current_user: str,
124
125
  claimed_email: Optional[str],
126
+ language: str = DEFAULT_LANGUAGE,
125
127
  ) -> Optional[str]:
126
128
  if current_user and claimed_email:
127
129
  if current_user.strip().lower() != claimed_email.strip().lower():
128
130
  raise HTTPException(
129
131
  status_code=403,
130
- detail="user_email must match the authenticated user.",
132
+ detail=translate("common.user_mismatch", language),
131
133
  )
132
134
  return current_user or claimed_email or None
133
135
 
@@ -239,12 +241,12 @@ def create_chat_router(context: AppContext) -> APIRouter:
239
241
  notify=notify_chat_message,
240
242
  )
241
243
 
242
- def request_model(model_id: Optional[str]) -> Optional[str]:
244
+ def request_model(model_id: Optional[str], language: str = DEFAULT_LANGUAGE) -> Optional[str]:
243
245
  selected = model_id or model_router.current_model_id
244
246
  if model_id and model_id not in model_router.loaded_model_ids:
245
247
  raise HTTPException(
246
248
  status_code=404,
247
- detail=f"Model '{model_id}' not loaded.",
249
+ detail=translate("chat.model_not_loaded", language, model=model_id),
248
250
  )
249
251
  return selected
250
252
 
@@ -258,7 +260,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
258
260
  len(req.image_data) if req.image_data else 0,
259
261
  len(req.message or ""),
260
262
  )
261
- effective_email = authenticated_identity(current_user, req.user_email)
263
+ effective_email = authenticated_identity(current_user, req.user_email, resolve_language(request))
262
264
  workspace_id = write_workspace(
263
265
  workspace_scope_from_request(request),
264
266
  current_user,
@@ -293,7 +295,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
293
295
  history_user=history_user,
294
296
  )
295
297
 
296
- selected_model_id = request_model(req.model)
298
+ selected_model_id = request_model(req.model, resolve_language(request))
297
299
  file_intent = is_file_action_request(req.message)
298
300
  if file_intent and context.funnel_metrics is not None:
299
301
  context.funnel_metrics.increment("file_requests")
@@ -303,11 +305,12 @@ def create_chat_router(context: AppContext) -> APIRouter:
303
305
  model_id=selected_model_id,
304
306
  effective_email=effective_email,
305
307
  workspace_id=workspace_id,
308
+ language=resolve_language(request),
306
309
  )
307
310
  if direct_response is not None:
308
311
  return direct_response
309
312
  if not selected_model_id:
310
- return intent_controller.no_model_response()
313
+ return intent_controller.no_model_response(resolve_language(request))
311
314
  if file_intent:
312
315
  return await intent_controller.route_file_to_agent(
313
316
  req,
@@ -29,6 +29,7 @@ from latticeai.api.chat_helpers import (
29
29
  )
30
30
  from latticeai.api.chat_stream import agent_live_stream
31
31
  from latticeai.core.agent import AgentRunContext, AgentState, normalize_plan
32
+ from latticeai.core.messages import resolve_language
32
33
  from latticeai.core.quiet import quiet
33
34
  from latticeai.core.run_explain import explain_run
34
35
 
@@ -266,7 +267,7 @@ class AgentHTTPController:
266
267
  """
267
268
  current_user = self.require_user(request)
268
269
  self.enforce_rate_limit(current_user, "agent")
269
- effective_email = self.authenticated_identity(current_user, req.user_email)
270
+ effective_email = self.authenticated_identity(current_user, req.user_email, resolve_language(request))
270
271
  header_workspace = workspace_scope_from_request(request)
271
272
  if req.workspace_id and header_workspace and req.workspace_id != header_workspace:
272
273
  raise HTTPException(
@@ -9,7 +9,9 @@ from __future__ import annotations
9
9
  from dataclasses import dataclass
10
10
  from typing import Any, Callable, Dict
11
11
 
12
- from fastapi import APIRouter, HTTPException, Request
12
+ from fastapi import APIRouter, Request
13
+
14
+ from latticeai.core.messages import http_error, resolve_language
13
15
 
14
16
 
15
17
  @dataclass(frozen=True)
@@ -48,7 +50,7 @@ def register_history_routes(router: APIRouter, deps: HistoryRouteDependencies) -
48
50
  **deps.scope_for_user(current_user),
49
51
  )
50
52
  if not messages:
51
- raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
53
+ raise http_error(404, "chat.conversation_not_found", resolve_language(request))
52
54
  return {"id": conversation_id, "messages": messages}
53
55
 
54
56
  @router.delete("/history/conversations/{conversation_id:path}")
@@ -31,9 +31,10 @@ from latticeai.core.file_generation import (
31
31
  repair_bundle_references,
32
32
  validate_project_bundle,
33
33
  )
34
+ from latticeai.core.messages import DEFAULT_LANGUAGE, translate
34
35
 
35
36
 
36
- def next_available_path(root: Path, target: str) -> str:
37
+ def next_available_path(root: Path, target: str, language: str = DEFAULT_LANGUAGE) -> str:
37
38
  """Return ``target`` unchanged, or a ``name_2.ext``-style variant when the
38
39
  file already exists in the workspace.
39
40
 
@@ -54,7 +55,7 @@ def next_available_path(root: Path, target: str) -> str:
54
55
  return str(Path(target).with_name(variant.name))
55
56
  raise HTTPException(
56
57
  status_code=409,
57
- detail=f"'{target}' 이름의 파일이 너무 많습니다. 다른 이름을 지정해 주세요.",
58
+ detail=translate("chat.file_name_collision", language, name=target),
58
59
  )
59
60
 
60
61
 
@@ -118,14 +119,10 @@ class ChatIntentController:
118
119
  except Exception as exc: # noqa: BLE001 — metrics must never break chat
119
120
  logging.warning("funnel metrics increment failed: %s", exc)
120
121
 
121
- def no_model_response(self) -> JSONResponse:
122
- detail = "No model loaded. Call /models/load first."
122
+ def no_model_response(self, language: str = DEFAULT_LANGUAGE) -> JSONResponse:
123
+ detail = translate("chat.no_model_loaded", language)
123
124
  if self.config.is_public:
124
- detail = (
125
- "No public model loaded. Set OPENAI_API_KEY and "
126
- f"LATTICEAI_PUBLIC_MODEL={self.public_model}, or call /models/load "
127
- "with an OpenAI-compatible model."
128
- )
125
+ detail = translate("models.public_model_missing", language, model=self.public_model)
129
126
  return JSONResponse(
130
127
  status_code=400,
131
128
  content={
@@ -249,6 +246,7 @@ class ChatIntentController:
249
246
  model_id: Optional[str],
250
247
  effective_email: Optional[str] = None,
251
248
  workspace_id: Optional[str] = None,
249
+ language: str = DEFAULT_LANGUAGE,
252
250
  ):
253
251
  # An explicit path ("report.txt") wins; otherwise infer one from an
254
252
  # explicit type keyword ("html 파일 만들어줘") so weak models never
@@ -266,19 +264,20 @@ class ChatIntentController:
266
264
  model_id=model_id,
267
265
  effective_email=effective_email,
268
266
  workspace_id=workspace_id,
267
+ language=language,
269
268
  )
270
269
  target_path = explicit_target or infer_file_target(req.message)
271
270
  if not target_path:
272
271
  return None
273
272
  # Never silently overwrite: an existing target gets a _2/_3 suffix so
274
273
  # regeneration is safe and repeatable without the proposal flow.
275
- deduped_path = next_available_path(self.agent_root, target_path)
274
+ deduped_path = next_available_path(self.agent_root, target_path, language)
276
275
  renamed = deduped_path != target_path
277
276
  target_path = deduped_path
278
277
  content = inline_file_action_content(req.message)
279
278
  generation_meta: Optional[Dict[str, Any]] = None
280
279
  if content is None and not model_id:
281
- return self.no_model_response()
280
+ return self.no_model_response(language)
282
281
  if content is None and model_id:
283
282
  # Model-agnostic pipeline: strict extension-aware prompt →
284
283
  # extraction → validation → one corrective retry → deterministic
@@ -302,7 +301,7 @@ class ChatIntentController:
302
301
  if content is None:
303
302
  raise HTTPException(
304
303
  status_code=400,
305
- detail="File content could not be generated.",
304
+ detail=translate("chat.file_generation_failed", language),
306
305
  )
307
306
  try:
308
307
  result = self.execute_tool(
@@ -395,6 +394,7 @@ class ChatIntentController:
395
394
  model_id: Optional[str],
396
395
  effective_email: Optional[str] = None,
397
396
  workspace_id: Optional[str] = None,
397
+ language: str = DEFAULT_LANGUAGE,
398
398
  ):
399
399
  """Artifact Loop: manifest → per-file generate/validate → bundle write.
400
400
 
@@ -405,8 +405,8 @@ class ChatIntentController:
405
405
  standard ``artifacts[]`` contract, and downloadable as a zip.
406
406
  """
407
407
  if not model_id:
408
- return self.no_model_response()
409
- project_dir = next_available_path(self.agent_root, manifest["name"])
408
+ return self.no_model_response(language)
409
+ project_dir = next_available_path(self.agent_root, manifest["name"], language)
410
410
  bundle_files = [str(f["path"]) for f in manifest["files"]]
411
411
 
412
412
  async def _generate(context: str) -> str:
@@ -19,6 +19,7 @@ from latticeai.api.workspace_scope import (
19
19
  resolve_workspace_scope,
20
20
  workspace_scope_from_request,
21
21
  )
22
+ from latticeai.core.messages import http_error, resolve_language
22
23
  from latticeai.core.quiet import quiet
23
24
 
24
25
 
@@ -456,9 +457,9 @@ def create_knowledge_graph_router(
456
457
  async def knowledge_graph_neighbors(node_id: str, request: Request):
457
458
  kg, allowed = _scoped(request)
458
459
  if not node_id:
459
- raise HTTPException(status_code=400, detail="node_id required")
460
+ raise http_error(400, "graph.node_id_required", resolve_language(request))
460
461
  if allowed is not None and not _filter_scoped(kg, [{"id": node_id}], allowed):
461
- raise HTTPException(status_code=404, detail="node not found")
462
+ raise http_error(404, "graph.node_not_found", resolve_language(request))
462
463
  payload = kg.neighbors(node_id)
463
464
  if allowed is not None:
464
465
  neighbors = _filter_scoped(kg, payload.get("neighbors", []), allowed)
@@ -476,13 +477,13 @@ def create_knowledge_graph_router(
476
477
  current_user = require_user(request)
477
478
  if current_user and req.user_email:
478
479
  if current_user.strip().lower() != req.user_email.strip().lower():
479
- raise HTTPException(status_code=403, detail="user_email must match the authenticated user.")
480
+ raise http_error(403, "common.user_mismatch", resolve_language(request))
480
481
  effective_user = current_user or req.user_email or None
481
482
  kg = graph()
482
483
  workspace_id = _write_workspace(request, current_user)
483
484
  event_type = (req.type or "").strip().lower()
484
485
  if event_type not in {"message", "ai_response", "note"}:
485
- raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
486
+ raise http_error(400, "graph.unsupported_type", resolve_language(request))
486
487
  role = req.role or ("assistant" if event_type == "ai_response" else "user")
487
488
  if ingestion_pipeline is not None:
488
489
  source_type = "chat_message" if event_type in {"message", "ai_response"} else "note"
@@ -15,6 +15,12 @@ from fastapi.responses import FileResponse
15
15
  from pydantic import BaseModel
16
16
 
17
17
  from latticeai.api.knowledge_graph import create_knowledge_graph_router
18
+ from latticeai.core.messages import (
19
+ DEFAULT_LANGUAGE,
20
+ http_error,
21
+ resolve_language,
22
+ translate,
23
+ )
18
24
  from latticeai.services.local_knowledge import create_local_knowledge_router
19
25
  from latticeai.tools import local_list, local_read, local_write
20
26
 
@@ -234,20 +240,20 @@ def create_local_files_router(
234
240
  )
235
241
  target = Path(path).expanduser().resolve()
236
242
  if not target.exists() or not target.is_file():
237
- raise HTTPException(status_code=404, detail="File not found")
243
+ raise http_error(404, "common.file_not_found", resolve_language(request))
238
244
  return FileResponse(str(target))
239
245
 
240
246
  # ── v9.8.0 ingestion jobs API (frozen paths — consumed by the frontend) ───
241
247
  def _require_pipeline():
242
248
  if ingestion_pipeline is None or not ingestion_pipeline.available():
243
- raise HTTPException(status_code=503, detail="Knowledge Graph ingestion is disabled.")
249
+ raise http_error(503, "capture.ingestion_disabled", DEFAULT_LANGUAGE)
244
250
 
245
251
  def _ingestion_write_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
246
252
  header = request.headers.get("X-Workspace-Id")
247
253
  header = header.strip() if header and header.strip() else None
248
254
  supplied = [value for value in (body_workspace, header) if value]
249
255
  if len(set(supplied)) > 1:
250
- raise HTTPException(status_code=403, detail="Workspace selectors must match.")
256
+ raise http_error(403, "common.workspace_mismatch", resolve_language(request))
251
257
  requested = supplied[0] if supplied else None
252
258
  if workspace_service is None:
253
259
  return requested
@@ -271,7 +277,7 @@ def create_local_files_router(
271
277
  _require_pipeline()
272
278
  job = ingestion_pipeline.get_background_job(job_id)
273
279
  if job is None:
274
- raise HTTPException(status_code=404, detail="ingestion job not found")
280
+ raise http_error(404, "ingestion.job_not_found", resolve_language(request))
275
281
  return job.as_dict()
276
282
 
277
283
  @router.post("/api/ingestion/jobs/{job_id}/resume")
@@ -281,7 +287,7 @@ def create_local_files_router(
281
287
  _require_pipeline()
282
288
  job = ingestion_pipeline.get_background_job(job_id)
283
289
  if job is None:
284
- raise HTTPException(status_code=404, detail="ingestion job not found")
290
+ raise http_error(404, "ingestion.job_not_found", resolve_language(request))
285
291
  if job.status == "running":
286
292
  return {"status": "already_running", "job_id": job_id, "job": job.as_dict()}
287
293
  remaining = len(job.remaining_indices())
@@ -312,7 +318,7 @@ def create_local_files_router(
312
318
  workspace_id = _ingestion_write_workspace(request, req.workspace_id, current_user)
313
319
  path = (req.path or "").strip()
314
320
  if not path:
315
- raise HTTPException(status_code=400, detail="path is required.")
321
+ raise http_error(400, "common.path_required", resolve_language(request))
316
322
  if not req.approved:
317
323
  return permission_gateway.local_permission_response(path, "read", current_user)
318
324
  permission_gateway.require_local_approval(
@@ -339,16 +345,16 @@ def create_local_files_router(
339
345
  return summary
340
346
 
341
347
  # ── folder watch mode: opt-in, off by default (backlog #8) ────────────────
342
- def _require_folder_watch():
348
+ def _require_folder_watch(request: Request):
343
349
  _require_pipeline()
344
350
  if folder_watch is None:
345
- raise HTTPException(status_code=503, detail="Folder watch service is unavailable.")
351
+ raise http_error(503, "ingestion.watch_unavailable", resolve_language(request))
346
352
 
347
353
  @router.get("/api/ingestion/watch")
348
354
  async def folder_watch_status(request: Request):
349
355
  """Watch-mode status: stored opt-ins, poller state, last scan results."""
350
356
  require_user(request)
351
- _require_folder_watch()
357
+ _require_folder_watch(request)
352
358
  return folder_watch.status()
353
359
 
354
360
  @router.post("/api/ingestion/watch")
@@ -361,11 +367,11 @@ def create_local_files_router(
361
367
  watch owner's workspace scope).
362
368
  """
363
369
  current_user = permission_gateway.require_local_user(request)
364
- _require_folder_watch()
370
+ _require_folder_watch(request)
365
371
  workspace_id = _ingestion_write_workspace(request, req.workspace_id, current_user)
366
372
  path = (req.path or "").strip()
367
373
  if not path:
368
- raise HTTPException(status_code=400, detail="path is required.")
374
+ raise http_error(400, "common.path_required", resolve_language(request))
369
375
  if not req.approved:
370
376
  return permission_gateway.local_permission_response(path, "read", current_user)
371
377
  permission_gateway.require_local_approval(
@@ -381,7 +387,11 @@ def create_local_files_router(
381
387
  recursive=req.recursive,
382
388
  )
383
389
  if result.get("status") != "ok":
384
- raise HTTPException(status_code=400, detail=result.get("detail") or "watch enable failed")
390
+ raise HTTPException(
391
+ status_code=400,
392
+ detail=result.get("detail")
393
+ or translate("ingestion.watch_enable_failed", resolve_language(request)),
394
+ )
385
395
  return result
386
396
 
387
397
  @router.delete("/api/ingestion/watch")
@@ -392,12 +402,12 @@ def create_local_files_router(
392
402
  ):
393
403
  """Opt back out of watch mode (removes the stored consent record)."""
394
404
  require_user(request)
395
- _require_folder_watch()
405
+ _require_folder_watch(request)
396
406
  if not watch_id and not path:
397
- raise HTTPException(status_code=400, detail="watch_id or path is required.")
407
+ raise http_error(400, "ingestion.watch_selector_required", resolve_language(request))
398
408
  result = folder_watch.disable(watch_id=watch_id, path=path)
399
409
  if result.get("status") == "not_found":
400
- raise HTTPException(status_code=404, detail="watch not found")
410
+ raise http_error(404, "ingestion.watch_not_found", resolve_language(request))
401
411
  return result
402
412
 
403
413
  @router.post("/local/write")