ltcai 10.7.0 → 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 +52 -39
- package/docs/CHANGELOG.md +31 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -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 +9 -6
- package/latticeai/api/auth.py +37 -38
- package/latticeai/api/browser.py +7 -6
- 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 +24 -207
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_relationships.py +99 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_screenshot_pixel_delta.py +92 -10
- 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 -37
- package/static/app/assets/{Act-DIbkoJqs.js → Act-CbQNsFgc.js} +1 -1
- package/static/app/assets/{AdminConsole-Cbi6Jre6.js → AdminConsole-Bs8SR8fE.js} +1 -1
- package/static/app/assets/{Brain-BPfuXZ71.js → Brain-BZIPsZlH.js} +1 -1
- package/static/app/assets/{BrainHome-87XaAS4V.js → BrainHome-BaH5MDce.js} +1 -1
- package/static/app/assets/{BrainSignals-ExpcdiUi.js → BrainSignals-aDt-nSsW.js} +1 -1
- package/static/app/assets/{Capture-Bxe4WiVX.js → Capture-DDv940Bv.js} +1 -1
- package/static/app/assets/{CommandPalette-B1PV5Yd4.js → CommandPalette-Dq_MmPBl.js} +1 -1
- package/static/app/assets/{Library-BRlJJmwZ.js → Library-BkShbqmN.js} +1 -1
- package/static/app/assets/{LivingBrain-D61qG3WY.js → LivingBrain-CXb8094H.js} +1 -1
- package/static/app/assets/ProductFlow-BaiqFWLO.js +1 -0
- package/static/app/assets/{ReviewCard-BoiWv0Y1.js → ReviewCard-Be7E7X18.js} +1 -1
- package/static/app/assets/{System-Ci0HEtTJ.js → System-DCstbTxC.js} +1 -1
- package/static/app/assets/arrow-left-DC9Oquhs.js +1 -0
- package/static/app/assets/{bot-BaPt4PPn.js → bot-BR0eoeXY.js} +1 -1
- package/static/app/assets/{brain-BSkWaj3i.js → brain-BmWa1wea.js} +1 -1
- package/static/app/assets/{button-CKwtO1O0.js → button-U1nU7xX4.js} +1 -1
- package/static/app/assets/{circle-pause-CiUc5RRk.js → circle-pause-DG5KHjDL.js} +1 -1
- package/static/app/assets/{circle-play-BaTDxzR9.js → circle-play-Chhcuq90.js} +1 -1
- package/static/app/assets/{cpu-Dm1gHuP1.js → cpu-pkOSlT9h.js} +1 -1
- package/static/app/assets/{download-C32oGU-C.js → download-CSRwz923.js} +1 -1
- package/static/app/assets/{folder-open-Ch2jLVdD.js → folder-open-DiMJ7W_7.js} +1 -1
- package/static/app/assets/{hard-drive-BBjA5st8.js → hard-drive-iyLPAPdp.js} +1 -1
- package/static/app/assets/index-BZRvGOM2.css +2 -0
- package/static/app/assets/{index-J6h01X78.js → index-_ldE9TAX.js} +3 -3
- package/static/app/assets/{input-y6ABdKWY.js → input-e_r2hFAY.js} +1 -1
- package/static/app/assets/{permissionCopy-BEtc0Ihd.js → permissionCopy-9uOHktz3.js} +1 -1
- package/static/app/assets/{primitives-GSFXF2Pd.js → primitives-CnEAOmg2.js} +1 -1
- package/static/app/assets/search-veep4n6f.js +1 -0
- package/static/app/assets/{share-2-BgGq2A63.js → share-2-DLDJJKmI.js} +1 -1
- package/static/app/assets/{shield-alert-BYA491cj.js → shield-alert-BTtE-2EZ.js} +1 -1
- package/static/app/assets/{textarea-BAcim3fs.js → textarea-Bbzkcgqe.js} +1 -1
- package/static/app/assets/{useFocusTrap-qLg24jiv.js → useFocusTrap-DJzQd-Za.js} +1 -1
- package/static/app/assets/{useQuery-xkAu9eDX.js → useQuery-6R_tqHRe.js} +1 -1
- package/static/app/assets/{utils-CogdofAA.js → utils-XDsjcrhS.js} +1 -1
- package/static/app/assets/{workspace-BMvfx9_R.js → workspace-CwrG8LHq.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/ProductFlow-BxjxI8Aa.js +0 -1
- package/static/app/assets/arrow-left-DArdBUxd.js +0 -1
- package/static/app/assets/index-BV7Yf6Eq.css +0 -2
- package/static/app/assets/search-CaIZKyo_.js +0 -1
package/latticeai/api/auth.py
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
145
|
+
raise http_error(401, "auth.bad_credentials", lang)
|
|
149
146
|
if user.get("disabled"):
|
|
150
|
-
raise
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
328
|
+
raise http_error(401, "auth.login_required", lang)
|
|
331
329
|
if req.name is not None and not req.name.strip():
|
|
332
|
-
raise
|
|
330
|
+
raise http_error(400, "auth.name_required", lang)
|
|
333
331
|
if req.nickname is not None and not req.nickname.strip():
|
|
334
|
-
raise
|
|
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
|
|
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
|
|
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
|
|
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"}
|
package/latticeai/api/browser.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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",
|
|
@@ -312,9 +312,54 @@ def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
|
|
|
312
312
|
if "```" in content:
|
|
313
313
|
return False, "output still contains Markdown fences"
|
|
314
314
|
return True, "ok"
|
|
315
|
+
# Prose types (.md, .txt, .csv, …) have no grammar to check, which used to
|
|
316
|
+
# mean *nothing* was checked: a 1–4B model that answered "Sure! Here is the
|
|
317
|
+
# document you asked for:" and stopped had its sentence saved as the file,
|
|
318
|
+
# because the fence stripper only removes conversational lines it can
|
|
319
|
+
# recognise and the length guard on `looks_like_refusal` lets a wordy
|
|
320
|
+
# refusal through. The two checks below are the only ones that generalise
|
|
321
|
+
# without inventing a grammar: it must not still be wearing fences, and it
|
|
322
|
+
# must not be *only* an answer about the file.
|
|
323
|
+
if "```" in content:
|
|
324
|
+
return False, "output still contains Markdown fences"
|
|
325
|
+
if _looks_like_commentary(content):
|
|
326
|
+
return False, "the reply talks about the file instead of being the file"
|
|
315
327
|
return True, "ok"
|
|
316
328
|
|
|
317
329
|
|
|
330
|
+
# Openers that mean "I am about to give you the thing" — if the whole reply is
|
|
331
|
+
# one of these, the thing never arrived.
|
|
332
|
+
_COMMENTARY_RE = re.compile(
|
|
333
|
+
r"^\s*("
|
|
334
|
+
r"(sure|of course|certainly|okay|ok|alright|here|below|the following)\b"
|
|
335
|
+
r"|i('ve| have| will|'ll)\b"
|
|
336
|
+
r"|(물론|네[,!. ]|알겠|다음은|아래(는|의)?|요청하신|원하시는)"
|
|
337
|
+
r")",
|
|
338
|
+
re.IGNORECASE,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _looks_like_commentary(content: str) -> bool:
|
|
343
|
+
"""True when the reply reads as an answer *about* a file, not the file.
|
|
344
|
+
|
|
345
|
+
Deliberately conservative — a long document that merely opens with "The
|
|
346
|
+
following" is a document. Only a short reply that both opens
|
|
347
|
+
conversationally and never grows into content is rejected, so a real file
|
|
348
|
+
is never thrown away to catch a chat line.
|
|
349
|
+
"""
|
|
350
|
+
stripped = content.strip()
|
|
351
|
+
if len(stripped) > 400:
|
|
352
|
+
return False
|
|
353
|
+
if not _COMMENTARY_RE.match(stripped):
|
|
354
|
+
return False
|
|
355
|
+
# Structure means content arrived after the preamble: a heading, a list, a
|
|
356
|
+
# table row, a delimiter, or simply several lines of body text.
|
|
357
|
+
body = stripped.split("\n", 1)[1].strip() if "\n" in stripped else ""
|
|
358
|
+
if re.search(r"^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|\||>)", body, re.MULTILINE):
|
|
359
|
+
return False
|
|
360
|
+
return len(body) < 120
|
|
361
|
+
|
|
362
|
+
|
|
318
363
|
# ── prompting ───────────────────────────────────────────────────────────
|
|
319
364
|
|
|
320
365
|
_FIRST_LINE_HINTS: Dict[str, str] = {
|
|
@@ -869,14 +914,28 @@ async def generate_file_content(
|
|
|
869
914
|
"""Generate validated file content with any LLM.
|
|
870
915
|
|
|
871
916
|
``generate`` is an async callable ``context -> raw model text``. Runs up
|
|
872
|
-
to ``max_attempts`` model calls (
|
|
917
|
+
to ``max_attempts`` model calls (each retry carrying corrective feedback),
|
|
873
918
|
then falls back to deterministic repair, so the returned content is
|
|
874
919
|
always non-empty and structurally valid for the target type.
|
|
920
|
+
|
|
921
|
+
One extra call beyond ``max_attempts`` is spent — at most once per
|
|
922
|
+
request — when the model has returned a byte-identical rejected reply.
|
|
923
|
+
That is the one case where the ordinary retry is known to be dead on
|
|
924
|
+
arrival: the corrective feedback did not change the reply, so the budget
|
|
925
|
+
is better spent on a prompt that names the repetition than on a third
|
|
926
|
+
identical round trip. Small local models hit this constantly; large ones
|
|
927
|
+
never do, so the extra call is not charged to models that do not need it.
|
|
875
928
|
"""
|
|
876
929
|
attempts: List[Dict[str, Any]] = []
|
|
877
930
|
feedback: Optional[str] = None
|
|
878
|
-
|
|
879
|
-
|
|
931
|
+
best_candidate = ""
|
|
932
|
+
best_score = (-1, -1)
|
|
933
|
+
seen: set[str] = set()
|
|
934
|
+
escalations_left = 1
|
|
935
|
+
attempt = 0
|
|
936
|
+
budget = max_attempts
|
|
937
|
+
while attempt < budget:
|
|
938
|
+
attempt += 1
|
|
880
939
|
context = build_file_generation_context(
|
|
881
940
|
target_path, user_request, feedback=feedback, bundle_files=bundle_files,
|
|
882
941
|
)
|
|
@@ -888,16 +947,90 @@ async def generate_file_content(
|
|
|
888
947
|
continue
|
|
889
948
|
candidate = extract_file_content(str(raw or ""), target_path)
|
|
890
949
|
ok, reason = validate_file_content(candidate, target_path)
|
|
891
|
-
|
|
950
|
+
record: Dict[str, Any] = {"attempt": attempt, "valid": ok, "reason": reason}
|
|
892
951
|
if ok:
|
|
952
|
+
attempts.append(record)
|
|
893
953
|
return candidate, {"attempts": attempts, "repaired": False}
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
954
|
+
|
|
955
|
+
# A small model handed the same corrective feedback often replays the
|
|
956
|
+
# same reply verbatim. Saying "you sent this before" is the only signal
|
|
957
|
+
# left that has any chance of moving it, and it makes the wasted retry
|
|
958
|
+
# visible in the trace instead of looking like two genuine tries.
|
|
959
|
+
fingerprint = candidate.strip()
|
|
960
|
+
repeated = fingerprint in seen and bool(fingerprint)
|
|
961
|
+
record["repeated"] = repeated
|
|
962
|
+
seen.add(fingerprint)
|
|
963
|
+
if repeated and escalations_left and attempt >= budget:
|
|
964
|
+
# The retry budget is exhausted and the last thing it bought was a
|
|
965
|
+
# duplicate. Buy one more, but only with a prompt that says so.
|
|
966
|
+
escalations_left -= 1
|
|
967
|
+
budget += 1
|
|
968
|
+
record["escalated"] = True
|
|
969
|
+
attempts.append(record)
|
|
970
|
+
|
|
971
|
+
# Keep the candidate that is *closest to a file*, not the longest one.
|
|
972
|
+
# Longest-wins handed repair a 900-character apology in preference to a
|
|
973
|
+
# 300-character HTML document that only needed its </html> closing —
|
|
974
|
+
# and repair can finish the document but can only bury the apology.
|
|
975
|
+
score = _salvage_score(candidate, target_path)
|
|
976
|
+
if score > best_score:
|
|
977
|
+
best_score, best_candidate = score, candidate
|
|
978
|
+
|
|
979
|
+
feedback = (
|
|
980
|
+
f"{reason}. You already sent exactly this reply and it was rejected "
|
|
981
|
+
"for the same reason — do not repeat it. Output the file itself, "
|
|
982
|
+
"starting at its first character."
|
|
983
|
+
if repeated else reason
|
|
984
|
+
)
|
|
985
|
+
repaired = repair_file_content(best_candidate, target_path, user_request)
|
|
898
986
|
return repaired, {"attempts": attempts, "repaired": True}
|
|
899
987
|
|
|
900
988
|
|
|
989
|
+
def _salvage_score(candidate: str, target_path: str) -> Tuple[int, int]:
|
|
990
|
+
"""How useful an invalid candidate is as raw material for repair.
|
|
991
|
+
|
|
992
|
+
``(tier, length)`` — tier first, so a short real document always beats a
|
|
993
|
+
long non-document; length breaks ties within a tier.
|
|
994
|
+
|
|
995
|
+
Tier 2 something of the right shape that repair can finish (an HTML
|
|
996
|
+
document missing its close tag, parseable-ish JSON, Python that
|
|
997
|
+
at least tokenises).
|
|
998
|
+
Tier 1 ordinary text: no structure, but the words may be the content.
|
|
999
|
+
Tier 0 a refusal — repair should prefer literally anything else, because
|
|
1000
|
+
an apology written into the file is worse than an empty stub.
|
|
1001
|
+
"""
|
|
1002
|
+
text = candidate.strip()
|
|
1003
|
+
if not text:
|
|
1004
|
+
return (0, 0)
|
|
1005
|
+
if looks_like_refusal(text):
|
|
1006
|
+
return (0, len(text))
|
|
1007
|
+
|
|
1008
|
+
ext = _ext(target_path)
|
|
1009
|
+
lower = text.lower()
|
|
1010
|
+
if ext in (".html", ".htm"):
|
|
1011
|
+
if lower.startswith("<!doctype") or lower.startswith("<html"):
|
|
1012
|
+
return (2, len(text))
|
|
1013
|
+
elif ext == ".json":
|
|
1014
|
+
if _slice_json_document(text) is not None:
|
|
1015
|
+
return (2, len(text))
|
|
1016
|
+
elif ext == ".py":
|
|
1017
|
+
try:
|
|
1018
|
+
ast.parse(text)
|
|
1019
|
+
except SyntaxError:
|
|
1020
|
+
pass
|
|
1021
|
+
else:
|
|
1022
|
+
return (2, len(text))
|
|
1023
|
+
elif ext in _BRACED_CODE_EXTENSIONS:
|
|
1024
|
+
if _check_balanced_delimiters(text)[0]:
|
|
1025
|
+
return (2, len(text))
|
|
1026
|
+
elif ext in _COMPONENT_EXTENSIONS:
|
|
1027
|
+
if _check_component_blocks(text)[0]:
|
|
1028
|
+
return (2, len(text))
|
|
1029
|
+
elif ext == ".css" and "{" in text and "}" in text:
|
|
1030
|
+
return (2, len(text))
|
|
1031
|
+
return (1, len(text))
|
|
1032
|
+
|
|
1033
|
+
|
|
901
1034
|
__all__ = [
|
|
902
1035
|
"PREVIEWABLE_EXTENSIONS",
|
|
903
1036
|
"build_file_generation_context",
|
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
from copy import deepcopy
|
|
11
11
|
from typing import Any, Dict, List, Optional
|
|
12
12
|
|
|
13
|
-
MARKETPLACE_VERSION = "10.
|
|
13
|
+
MARKETPLACE_VERSION = "10.8.0"
|
|
14
14
|
TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
|
|
15
15
|
|
|
16
16
|
|