ltcai 9.9.1 → 9.9.3
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 +40 -39
- package/docs/CHANGELOG.md +80 -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/curator.py +141 -0
- package/lattice_brain/graph/documents.py +111 -0
- package/lattice_brain/graph/fusion.py +179 -0
- package/lattice_brain/graph/projection.py +161 -0
- package/lattice_brain/graph/retrieval.py +19 -1
- package/lattice_brain/ingestion.py +66 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/automation_intelligence.py +159 -0
- package/latticeai/api/browser.py +15 -4
- package/latticeai/api/chat.py +30 -2
- package/latticeai/api/chat_agent_http.py +193 -4
- package/latticeai/api/chat_contracts.py +7 -1
- package/latticeai/api/chat_helpers.py +147 -0
- package/latticeai/api/chat_intents.py +319 -8
- package/latticeai/api/chat_stream.py +17 -1
- package/latticeai/api/funnel_metrics.py +31 -0
- package/latticeai/api/knowledge_graph.py +30 -0
- package/latticeai/api/local_files.py +90 -0
- package/latticeai/api/setup.py +134 -1
- package/latticeai/api/tools.py +33 -3
- package/latticeai/app_factory.py +15 -0
- package/latticeai/core/agent.py +205 -11
- package/latticeai/core/agent_eval.py +107 -0
- package/latticeai/core/file_generation.py +337 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/persistence_runtime.py +19 -1
- package/latticeai/runtime/router_registration.py +8 -1
- package/latticeai/services/app_context.py +6 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_execution.py +266 -0
- package/latticeai/services/automation_intelligence.py +7 -0
- package/latticeai/services/command_center.py +20 -1
- package/latticeai/services/folder_watch.py +391 -0
- package/latticeai/services/funnel_metrics.py +202 -0
- package/latticeai/services/product_readiness.py +6 -1
- package/latticeai/services/search_service.py +17 -1
- package/latticeai/services/tool_dispatch.py +36 -0
- package/latticeai/setup/demo_corpus.py +98 -0
- package/latticeai/tools/filesystem.py +38 -1
- package/package.json +1 -1
- package/scripts/bench_models.py +234 -5
- package/scripts/check_current_release_docs.mjs +1 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +28 -27
- package/static/app/assets/Act-DmdruVKV.js +1 -0
- package/static/app/assets/{Brain-CSCDZEHd.js → Brain-DEY9jLVt.js} +2 -2
- package/static/app/assets/BrainHome-CeNaxjP1.js +2 -0
- package/static/app/assets/BrainSignals-CStjIqYi.js +1 -0
- package/static/app/assets/{Capture-BiuerOio.js → Capture-CVZ09QXi.js} +1 -1
- package/static/app/assets/CommandPalette-DepwOQFv.js +1 -0
- package/static/app/assets/Library-Bp0n-HlW.js +1 -0
- package/static/app/assets/{LivingBrain-6FvA0H5T.js → LivingBrain-DxP4efJF.js} +1 -1
- package/static/app/assets/{ProductFlow-Cr8bkQPw.js → ProductFlow-DRbm7NEq.js} +1 -1
- package/static/app/assets/ReviewCard-C4HAO7A3.js +3 -0
- package/static/app/assets/{System-D6uJacqu.js → System-ByQcmJW-.js} +1 -1
- package/static/app/assets/{bot-DgC7xgUl.js → bot-BNDyZLR7.js} +1 -1
- package/static/app/assets/circle-play-BkhdcHgd.js +1 -0
- package/static/app/assets/{cpu-DDGb4XHl.js → cpu-C6jjYm6i.js} +1 -1
- package/static/app/assets/{folder-open-BbVuSO9M.js → folder-open-DjGIvDBQ.js} +1 -1
- package/static/app/assets/{hard-drive-yDZR6XYz.js → hard-drive-BlSbwSaT.js} +1 -1
- package/static/app/assets/index-Bge3DXW7.css +2 -0
- package/static/app/assets/{index-DrZSkwrE.js → index-CHu7cgj3.js} +3 -3
- package/static/app/assets/{input-DPIkTs9n.js → input-DVDI0YR3.js} +1 -1
- package/static/app/assets/{navigation-B1mkmyUe.js → navigation-BddhEWA0.js} +1 -1
- package/static/app/assets/{network-0XDI1Uf_.js → network-pYQt5oBu.js} +1 -1
- package/static/app/assets/{primitives-sIQYKyf5.js → primitives-D7gCdEvS.js} +1 -1
- package/static/app/assets/shield-alert-K9RKGQeg.js +1 -0
- package/static/app/assets/{textarea-DvnwpnqS.js → textarea-sqQmoBKL.js} +1 -1
- package/static/app/assets/useFocusTrap-7EV9dFP2.js +1 -0
- package/static/app/assets/utils-uQYKXNeq.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-CcSWTQsx.js +0 -1
- package/static/app/assets/BrainHome-B5QpJ1Fx.js +0 -2
- package/static/app/assets/BrainSignals-CmFD8PWk.js +0 -1
- package/static/app/assets/CommandPalette-BrqpT7K2.js +0 -1
- package/static/app/assets/Library-BcaiPbum.js +0 -1
- package/static/app/assets/ReviewCard-D56TMypk.js +0 -1
- package/static/app/assets/download-C-Ei3GFa.js +0 -1
- package/static/app/assets/index-CFRhNBuH.css +0 -2
- package/static/app/assets/sunrise-DXMjueec.js +0 -1
- package/static/app/assets/utils-BrfFHAJl.js +0 -7
|
@@ -163,6 +163,18 @@ class Scenario:
|
|
|
163
163
|
# canned results (node ids, snippets, proposal ids), so an answer that is
|
|
164
164
|
# not grounded in the retrieved/executed evidence fails the scenario.
|
|
165
165
|
expect_final_contains: List[str] = field(default_factory=list)
|
|
166
|
+
# Exact per-name counts against the LoopTrace ``repairs`` histogram —
|
|
167
|
+
# filegen scenarios use this to prove the ArtifactWritePipeline actually
|
|
168
|
+
# fired (``artifact_sanitize`` / ``artifact_repair``), not merely that
|
|
169
|
+
# the tool ran.
|
|
170
|
+
expect_repairs: Dict[str, int] = field(default_factory=dict)
|
|
171
|
+
# Content-level assertions on what file-writing tools actually received
|
|
172
|
+
# AFTER sanitize: every ``contains`` needle must appear in at least one
|
|
173
|
+
# written payload, every ``excludes`` needle in none. This is the teeth
|
|
174
|
+
# of the dirty-write scenarios — a fence or chat wrapper reaching the
|
|
175
|
+
# tool port fails the gate.
|
|
176
|
+
expect_write_contains: List[str] = field(default_factory=list)
|
|
177
|
+
expect_write_excludes: List[str] = field(default_factory=list)
|
|
166
178
|
max_steps: int = 8
|
|
167
179
|
|
|
168
180
|
|
|
@@ -247,6 +259,37 @@ _WRITE = '{"action": "write_file", "args": {"path": "note.txt", "content": "hi"}
|
|
|
247
259
|
_FINAL = '{"action": "final", "message": "done"}'
|
|
248
260
|
_PASS = '{"action": "verdict", "verdict": "PASS", "next_state": "DONE", "reason": "ok"}'
|
|
249
261
|
|
|
262
|
+
# ── dirty write_file payloads (ArtifactWritePipeline scenarios) ──────────
|
|
263
|
+
# What weak local models actually put in args.content: chat framing + a
|
|
264
|
+
# Markdown fence around the document. The JSON envelope itself is valid —
|
|
265
|
+
# the dirt is *inside* the content string, so only the write-side sanitize
|
|
266
|
+
# pass (agent.py _dispatch_step → sanitize_write_content) can clean it.
|
|
267
|
+
_DIRTY_HTML_CONTENT = (
|
|
268
|
+
"Sure! Here is your page:\\n"
|
|
269
|
+
"```html\\n"
|
|
270
|
+
"<!DOCTYPE html>\\n<html>\\n<head><meta charset=\\\"utf-8\\\">"
|
|
271
|
+
"<title>Hello</title></head>\\n<body><h1>Hello</h1></body>\\n</html>\\n"
|
|
272
|
+
"```\\n"
|
|
273
|
+
"Hope this helps! Let me know if you need changes."
|
|
274
|
+
)
|
|
275
|
+
_DIRTY_WRITE = (
|
|
276
|
+
'{"thoughts": "produce the page", "action": "write_file", '
|
|
277
|
+
'"args": {"path": "pages/hello.html", "content": "' + _DIRTY_HTML_CONTENT + '"}}'
|
|
278
|
+
)
|
|
279
|
+
# Token-limit casualty: the document just stops mid-body (no </body>/</html>).
|
|
280
|
+
_TRUNCATED_HTML_CONTENT = (
|
|
281
|
+
"<!DOCTYPE html>\\n<html>\\n<head><meta charset=\\\"utf-8\\\">"
|
|
282
|
+
"<title>Weekly</title></head>\\n<body>\\n<h1>Weekly report</h1>"
|
|
283
|
+
)
|
|
284
|
+
_TRUNCATED_WRITE = (
|
|
285
|
+
'{"thoughts": "write the report", "action": "write_file", '
|
|
286
|
+
'"args": {"path": "reports/weekly.html", "content": "' + _TRUNCATED_HTML_CONTENT + '"}}'
|
|
287
|
+
)
|
|
288
|
+
_FILEGEN_PLAN = (
|
|
289
|
+
'{"action": "plan", "goal": "save the requested web page to disk", '
|
|
290
|
+
'"steps": [{"action": "write_file"}]}'
|
|
291
|
+
)
|
|
292
|
+
|
|
250
293
|
|
|
251
294
|
def default_scenarios() -> List[Scenario]:
|
|
252
295
|
return [
|
|
@@ -551,6 +594,54 @@ def default_scenarios() -> List[Scenario]:
|
|
|
551
594
|
expected_class="failed",
|
|
552
595
|
expect_tool_outcomes={"error": 1},
|
|
553
596
|
),
|
|
597
|
+
# ── ArtifactWritePipeline (write-side sanitize) ─────────────────
|
|
598
|
+
Scenario(
|
|
599
|
+
name="filegen-dirty-write-sanitized-critic-pass",
|
|
600
|
+
# Weak model wraps args.content in prose + a Markdown fence. The
|
|
601
|
+
# sanitize pass must strip the wrapper BEFORE the tool runs, the
|
|
602
|
+
# trace must record artifact_sanitize, and the critic PASS then
|
|
603
|
+
# completes over a transcript whose written payload is clean.
|
|
604
|
+
replies=[_FILEGEN_PLAN, _DIRTY_WRITE, _FINAL, _PASS],
|
|
605
|
+
expect_exact={"parse_errors": 0},
|
|
606
|
+
expect_tool_outcomes={"ok": 1},
|
|
607
|
+
expect_tool_calls=["write_file"],
|
|
608
|
+
expect_repairs={"artifact_sanitize": 1},
|
|
609
|
+
expect_write_contains=["<!DOCTYPE html>", "</html>"],
|
|
610
|
+
expect_write_excludes=["```", "Sure!", "Hope this helps"],
|
|
611
|
+
),
|
|
612
|
+
Scenario(
|
|
613
|
+
name="filegen-truncated-write-repaired-critic-pass",
|
|
614
|
+
# Token-limit truncation: no extractable clean document exists, so
|
|
615
|
+
# the pipeline's deterministic repair must close the document
|
|
616
|
+
# (artifact_repair) — the file on disk is still structurally valid.
|
|
617
|
+
replies=[_FILEGEN_PLAN, _TRUNCATED_WRITE, _FINAL, _PASS],
|
|
618
|
+
expect_exact={"parse_errors": 0},
|
|
619
|
+
expect_tool_outcomes={"ok": 1},
|
|
620
|
+
expect_tool_calls=["write_file"],
|
|
621
|
+
expect_repairs={"artifact_repair": 1},
|
|
622
|
+
expect_write_contains=["<h1>Weekly report</h1>", "</body>", "</html>"],
|
|
623
|
+
expect_write_excludes=["```"],
|
|
624
|
+
),
|
|
625
|
+
Scenario(
|
|
626
|
+
name="filegen-dirty-write-unverifiable-needs-review",
|
|
627
|
+
# The dirty write is sanitized and runs, but the critic never
|
|
628
|
+
# produces a parseable verdict — fail-closed: the run must end
|
|
629
|
+
# NEEDS_REVIEW (never a fabricated DONE), while the written file
|
|
630
|
+
# stays clean because sanitize ran before the tool.
|
|
631
|
+
replies=[
|
|
632
|
+
_FILEGEN_PLAN, _DIRTY_WRITE, _FINAL,
|
|
633
|
+
"the page looks great, approving!", # unparseable critic
|
|
634
|
+
"still prose, still not a JSON verdict", # strict retry fails too
|
|
635
|
+
],
|
|
636
|
+
expect_state="NEEDS_REVIEW",
|
|
637
|
+
expected_class="needs_review",
|
|
638
|
+
expect_min={"parse_errors": 2},
|
|
639
|
+
expect_tool_outcomes={"ok": 1},
|
|
640
|
+
expect_tool_calls=["write_file"],
|
|
641
|
+
expect_repairs={"artifact_sanitize": 1},
|
|
642
|
+
expect_write_excludes=["```", "Sure!"],
|
|
643
|
+
expect_final_contains=["직접 확인"],
|
|
644
|
+
),
|
|
554
645
|
]
|
|
555
646
|
|
|
556
647
|
|
|
@@ -591,6 +682,22 @@ async def _run_scenario(scenario: Scenario) -> Dict[str, Any]:
|
|
|
591
682
|
for needle in scenario.expect_final_contains:
|
|
592
683
|
if needle not in ctx.final_message:
|
|
593
684
|
failures.append(f"final_message missing {needle!r}")
|
|
685
|
+
for repair_name, count in scenario.expect_repairs.items():
|
|
686
|
+
seen = int((summary.get("repairs") or {}).get(repair_name, 0))
|
|
687
|
+
if seen != count:
|
|
688
|
+
failures.append(f"repairs[{repair_name}]={seen} != {count}")
|
|
689
|
+
# Payloads the file-writing tools actually received (post-sanitize).
|
|
690
|
+
written = [
|
|
691
|
+
str((call.get("args") or {}).get("content") or "")
|
|
692
|
+
for call in tool_log
|
|
693
|
+
if call["name"] in ("write_file", "generate_file")
|
|
694
|
+
]
|
|
695
|
+
for needle in scenario.expect_write_contains:
|
|
696
|
+
if not any(needle in payload for payload in written):
|
|
697
|
+
failures.append(f"written content missing {needle!r}")
|
|
698
|
+
for needle in scenario.expect_write_excludes:
|
|
699
|
+
if any(needle in payload for payload in written):
|
|
700
|
+
failures.append(f"written content must not contain {needle!r}")
|
|
594
701
|
if governor is not None and summary["tool_outcomes"].get("proposed", 0) != len(governor.proposals):
|
|
595
702
|
failures.append(
|
|
596
703
|
f"governor proposals={len(governor.proposals)} != "
|
|
@@ -25,6 +25,7 @@ The pipeline is pure (no I/O, no FastAPI); the chat layer injects an async
|
|
|
25
25
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
|
+
import ast
|
|
28
29
|
import html as html_lib
|
|
29
30
|
import json
|
|
30
31
|
import re
|
|
@@ -91,6 +92,8 @@ _EXT_FENCE_LANGS: Dict[str, Tuple[str, ...]] = {
|
|
|
91
92
|
".xml": ("xml", "svg"),
|
|
92
93
|
".csv": ("csv",),
|
|
93
94
|
".txt": ("txt", "text", "plaintext"),
|
|
95
|
+
".vue": ("vue", "html"),
|
|
96
|
+
".svelte": ("svelte", "html"),
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
|
|
@@ -181,6 +184,76 @@ def looks_like_refusal(content: str) -> bool:
|
|
|
181
184
|
return bool(_REFUSAL_RE.search(head)) and len(content) < 600
|
|
182
185
|
|
|
183
186
|
|
|
187
|
+
# Braced code types validated structurally (balanced delimiters, no fences).
|
|
188
|
+
_BRACED_CODE_EXTENSIONS = frozenset({".js", ".jsx", ".ts", ".tsx"})
|
|
189
|
+
# Single-file components validated by their block tags being closed.
|
|
190
|
+
_COMPONENT_EXTENSIONS = frozenset({".vue", ".svelte"})
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _strip_code_literals(text: str) -> str:
|
|
194
|
+
"""Remove string literals and comments so delimiter counting stays honest.
|
|
195
|
+
|
|
196
|
+
A cheap single-pass scanner (not a parser): quotes ('', "", ``), line
|
|
197
|
+
comments (//) and block comments (/* */) commonly contain lone braces
|
|
198
|
+
that would otherwise false-flag valid JS/TS as unbalanced.
|
|
199
|
+
"""
|
|
200
|
+
out: List[str] = []
|
|
201
|
+
i, n = 0, len(text)
|
|
202
|
+
while i < n:
|
|
203
|
+
ch = text[i]
|
|
204
|
+
nxt = text[i + 1] if i + 1 < n else ""
|
|
205
|
+
if ch == "\\":
|
|
206
|
+
i += 2 # escaped char (inside or outside a literal — always skip)
|
|
207
|
+
continue
|
|
208
|
+
if ch in ("'", '"', "`"):
|
|
209
|
+
quote = ch
|
|
210
|
+
i += 1
|
|
211
|
+
while i < n:
|
|
212
|
+
if text[i] == "\\":
|
|
213
|
+
i += 2
|
|
214
|
+
continue
|
|
215
|
+
if text[i] == quote:
|
|
216
|
+
i += 1
|
|
217
|
+
break
|
|
218
|
+
i += 1
|
|
219
|
+
continue
|
|
220
|
+
if ch == "/" and nxt == "/":
|
|
221
|
+
while i < n and text[i] != "\n":
|
|
222
|
+
i += 1
|
|
223
|
+
continue
|
|
224
|
+
if ch == "/" and nxt == "*":
|
|
225
|
+
end = text.find("*/", i + 2)
|
|
226
|
+
i = n if end < 0 else end + 2
|
|
227
|
+
continue
|
|
228
|
+
out.append(ch)
|
|
229
|
+
i += 1
|
|
230
|
+
return "".join(out)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _check_balanced_delimiters(content: str) -> Tuple[bool, str]:
|
|
234
|
+
"""Lenient count-based balance check for braced code (js/ts family).
|
|
235
|
+
|
|
236
|
+
Only *counts* are compared, never ordering, so valid-but-unusual code is
|
|
237
|
+
not rejected; a truncated file with a dangling ``{`` still fails.
|
|
238
|
+
"""
|
|
239
|
+
stripped = _strip_code_literals(content)
|
|
240
|
+
for opener, closer, label in (("{", "}", "braces"), ("(", ")", "parentheses"), ("[", "]", "brackets")):
|
|
241
|
+
if stripped.count(opener) != stripped.count(closer):
|
|
242
|
+
return False, f"unbalanced {label} ({opener}{closer}) — the file looks truncated"
|
|
243
|
+
return True, "ok"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _check_component_blocks(content: str) -> Tuple[bool, str]:
|
|
247
|
+
"""Vue/Svelte SFC sanity: every opened block tag must be closed."""
|
|
248
|
+
lower = content.lower()
|
|
249
|
+
for tag in ("template", "script", "style"):
|
|
250
|
+
opened = len(re.findall(rf"<{tag}(?:\s[^>]*)?>", lower))
|
|
251
|
+
closed = lower.count(f"</{tag}>")
|
|
252
|
+
if opened != closed:
|
|
253
|
+
return False, f"<{tag}> block is not closed — the component looks truncated"
|
|
254
|
+
return True, "ok"
|
|
255
|
+
|
|
256
|
+
|
|
184
257
|
def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
|
|
185
258
|
"""Structural sanity check per file type. Returns (ok, reason)."""
|
|
186
259
|
if not content.strip():
|
|
@@ -195,6 +268,14 @@ def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
|
|
|
195
268
|
return False, "not a complete HTML document (missing <!DOCTYPE html>/<html>)"
|
|
196
269
|
if "</html>" not in lower:
|
|
197
270
|
return False, "HTML document is truncated (missing </html>)"
|
|
271
|
+
# A document that merely *contains* html somewhere is not a valid
|
|
272
|
+
# file payload: fenced/chat-wrapped replies must fail here so the
|
|
273
|
+
# extraction pass gets a chance to slice out the real document.
|
|
274
|
+
stripped = content.lstrip()
|
|
275
|
+
if not (stripped.lower().startswith("<!doctype") or stripped.lower().startswith("<html")):
|
|
276
|
+
return False, "HTML document is wrapped in prose or fences"
|
|
277
|
+
if "```" in content:
|
|
278
|
+
return False, "output still contains Markdown fences"
|
|
198
279
|
return True, "ok"
|
|
199
280
|
if ext == ".json":
|
|
200
281
|
try:
|
|
@@ -203,10 +284,28 @@ def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
|
|
|
203
284
|
return False, f"invalid JSON: {exc}"
|
|
204
285
|
return True, "ok"
|
|
205
286
|
if ext == ".css":
|
|
287
|
+
if "```" in content:
|
|
288
|
+
return False, "output still contains Markdown fences"
|
|
206
289
|
if "{" not in content or "}" not in content:
|
|
207
290
|
return False, "no CSS rule blocks found"
|
|
208
291
|
return True, "ok"
|
|
209
|
-
if ext
|
|
292
|
+
if ext == ".py":
|
|
293
|
+
if "```" in content:
|
|
294
|
+
return False, "output still contains Markdown fences"
|
|
295
|
+
try:
|
|
296
|
+
ast.parse(content)
|
|
297
|
+
except SyntaxError as exc:
|
|
298
|
+
return False, f"invalid Python syntax: {exc.msg} (line {exc.lineno})"
|
|
299
|
+
return True, "ok"
|
|
300
|
+
if ext in _BRACED_CODE_EXTENSIONS:
|
|
301
|
+
if "```" in content:
|
|
302
|
+
return False, "output still contains Markdown fences"
|
|
303
|
+
return _check_balanced_delimiters(content)
|
|
304
|
+
if ext in _COMPONENT_EXTENSIONS:
|
|
305
|
+
if "```" in content:
|
|
306
|
+
return False, "output still contains Markdown fences"
|
|
307
|
+
return _check_component_blocks(content)
|
|
308
|
+
if ext in (".sh", ".sql"):
|
|
210
309
|
if "```" in content:
|
|
211
310
|
return False, "output still contains Markdown fences"
|
|
212
311
|
return True, "ok"
|
|
@@ -240,13 +339,31 @@ _TYPE_RULES: Dict[str, str] = {
|
|
|
240
339
|
".csv": "Produce CSV with a header row; comma-separated, one record per line.",
|
|
241
340
|
".py": "Produce complete runnable Python source code.",
|
|
242
341
|
".js": "Produce complete valid JavaScript source code.",
|
|
342
|
+
".jsx": "Produce one complete React component file in JSX.",
|
|
343
|
+
".ts": "Produce complete valid TypeScript source code.",
|
|
344
|
+
".tsx": "Produce one complete React component file in TSX (TypeScript).",
|
|
345
|
+
".vue": "Produce ONE complete Vue single-file component with closed <template>/<script>/<style> blocks.",
|
|
346
|
+
".svelte": "Produce ONE complete Svelte component; every <script>/<style> block must be closed.",
|
|
243
347
|
}
|
|
244
348
|
|
|
349
|
+
# Multi-file bundles override the standalone-HTML rule: the page must link
|
|
350
|
+
# its sibling files instead of inlining everything.
|
|
351
|
+
_BUNDLE_HTML_RULE = (
|
|
352
|
+
"Produce ONE complete HTML5 document: <!DOCTYPE html>, <html>, <head> with "
|
|
353
|
+
"<meta charset=\"utf-8\"> and a <title>, and a closed </html> tag. "
|
|
354
|
+
"This page is part of a multi-file project: link the project stylesheet(s) "
|
|
355
|
+
"with <link rel=\"stylesheet\" href=\"...\"> and load the project script(s) "
|
|
356
|
+
"with <script src=\"...\"></script> just before </body>. Reference ONLY the "
|
|
357
|
+
"project files listed below — no other external files, no inline <style> "
|
|
358
|
+
"blocks, no inline behavior scripts."
|
|
359
|
+
)
|
|
360
|
+
|
|
245
361
|
|
|
246
362
|
def build_file_generation_context(
|
|
247
363
|
target_path: str,
|
|
248
364
|
user_request: str,
|
|
249
365
|
feedback: Optional[str] = None,
|
|
366
|
+
bundle_files: Optional[List[str]] = None,
|
|
250
367
|
) -> str:
|
|
251
368
|
"""Strict, extension-aware generation instructions.
|
|
252
369
|
|
|
@@ -263,8 +380,13 @@ def build_file_generation_context(
|
|
|
263
380
|
"no text before or after the content.",
|
|
264
381
|
]
|
|
265
382
|
type_rule = _TYPE_RULES.get(ext)
|
|
383
|
+
if bundle_files and ext in (".html", ".htm"):
|
|
384
|
+
type_rule = _BUNDLE_HTML_RULE
|
|
266
385
|
if type_rule:
|
|
267
386
|
parts.append(f"- {type_rule}")
|
|
387
|
+
if bundle_files:
|
|
388
|
+
listed = ", ".join(bundle_files)
|
|
389
|
+
parts.append(f"- Project files in this bundle: {listed}")
|
|
268
390
|
first_line = _FIRST_LINE_HINTS.get(ext)
|
|
269
391
|
if first_line:
|
|
270
392
|
parts.append(f"- The very first line of your reply must be: {first_line}")
|
|
@@ -301,12 +423,29 @@ def repair_file_content(content: str, target_path: str, user_request: str) -> st
|
|
|
301
423
|
ensure_ascii=False,
|
|
302
424
|
indent=2,
|
|
303
425
|
)
|
|
426
|
+
if ext == ".py" and salvage:
|
|
427
|
+
# The repair guarantee for Python is parseability: unparseable output
|
|
428
|
+
# is preserved honestly as a commented-out draft, never as a broken
|
|
429
|
+
# module the user has to debug.
|
|
430
|
+
try:
|
|
431
|
+
ast.parse(salvage)
|
|
432
|
+
return salvage
|
|
433
|
+
except SyntaxError:
|
|
434
|
+
commented = "\n".join(f"# {line}" for line in salvage.splitlines())
|
|
435
|
+
return (
|
|
436
|
+
f"# TODO: model produced invalid Python for: {user_request}\n"
|
|
437
|
+
"# The draft below is preserved as comments — fix and uncomment.\n"
|
|
438
|
+
f"{commented}\n"
|
|
439
|
+
)
|
|
304
440
|
if salvage:
|
|
305
441
|
return salvage
|
|
306
442
|
# Nothing usable at all — leave an honest placeholder in the right format.
|
|
307
443
|
comment = {
|
|
308
444
|
".py": "# TODO: model produced no usable content for: ",
|
|
309
445
|
".js": "// TODO: model produced no usable content for: ",
|
|
446
|
+
".jsx": "// TODO: model produced no usable content for: ",
|
|
447
|
+
".ts": "// TODO: model produced no usable content for: ",
|
|
448
|
+
".tsx": "// TODO: model produced no usable content for: ",
|
|
310
449
|
".css": "/* TODO: model produced no usable content for: ",
|
|
311
450
|
".sh": "# TODO: model produced no usable content for: ",
|
|
312
451
|
".sql": "-- TODO: model produced no usable content for: ",
|
|
@@ -359,6 +498,55 @@ def _repair_html(salvage: str, user_request: str) -> str:
|
|
|
359
498
|
)
|
|
360
499
|
|
|
361
500
|
|
|
501
|
+
# Extensions the Brain UI can render inline (preview) after creation.
|
|
502
|
+
PREVIEWABLE_EXTENSIONS = frozenset({
|
|
503
|
+
".html", ".htm", ".md", ".markdown", ".txt", ".json", ".css", ".js",
|
|
504
|
+
".csv", ".py", ".yaml", ".yml", ".xml", ".sql", ".sh",
|
|
505
|
+
".jsx", ".ts", ".tsx", ".vue", ".svelte",
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# ── write-side sanitize (ArtifactWritePipeline) ─────────────────────────
|
|
510
|
+
|
|
511
|
+
def sanitize_write_content(
|
|
512
|
+
target_path: str,
|
|
513
|
+
content: Any,
|
|
514
|
+
user_request: str = "",
|
|
515
|
+
) -> Tuple[str, Dict[str, Any]]:
|
|
516
|
+
"""Single write-side guarantee for model-produced file content.
|
|
517
|
+
|
|
518
|
+
The direct chat path already runs the full generate→validate→repair
|
|
519
|
+
pipeline, but the agent JSON loop historically wrote ``args.content``
|
|
520
|
+
verbatim — weak models routinely put fenced/chatty payloads there. This
|
|
521
|
+
conservative sanitizer closes that gap for *any* write entry point:
|
|
522
|
+
|
|
523
|
+
1. content that already validates is returned byte-for-byte unchanged
|
|
524
|
+
(trusted/user-authored content is never mangled);
|
|
525
|
+
2. otherwise the extraction pass strips fences/think-blocks/chat noise
|
|
526
|
+
and is used only when the extracted payload validates;
|
|
527
|
+
3. otherwise deterministic repair guarantees a structurally valid file.
|
|
528
|
+
|
|
529
|
+
Empty content is left untouched (creating an empty file is a legitimate,
|
|
530
|
+
intentional action — e.g. ``__init__.py``). Returns ``(content, meta)``
|
|
531
|
+
where meta is ``{"sanitized": bool, "repaired": bool, "reason": str}``.
|
|
532
|
+
"""
|
|
533
|
+
raw = str(content or "")
|
|
534
|
+
if not raw.strip():
|
|
535
|
+
return raw, {"sanitized": False, "repaired": False, "reason": "empty"}
|
|
536
|
+
ok, reason = validate_file_content(raw, target_path)
|
|
537
|
+
if ok:
|
|
538
|
+
return raw, {"sanitized": False, "repaired": False, "reason": "ok"}
|
|
539
|
+
extracted = extract_file_content(raw, target_path)
|
|
540
|
+
if extracted:
|
|
541
|
+
extracted_ok, _ = validate_file_content(extracted, target_path)
|
|
542
|
+
if extracted_ok:
|
|
543
|
+
return extracted, {"sanitized": True, "repaired": False, "reason": reason}
|
|
544
|
+
repaired = repair_file_content(
|
|
545
|
+
extracted or raw, target_path, user_request or f"content for {target_path}"
|
|
546
|
+
)
|
|
547
|
+
return repaired, {"sanitized": True, "repaired": True, "reason": reason}
|
|
548
|
+
|
|
549
|
+
|
|
362
550
|
# ── filename inference ──────────────────────────────────────────────────
|
|
363
551
|
|
|
364
552
|
_CREATE_VERB_RE = re.compile(
|
|
@@ -401,6 +589,145 @@ def infer_file_target(message: str) -> Optional[str]:
|
|
|
401
589
|
return None
|
|
402
590
|
|
|
403
591
|
|
|
592
|
+
# ── project manifest (multi-file bundles) ───────────────────────────────
|
|
593
|
+
|
|
594
|
+
# ``\b`` fails against Korean particles ("js로") because Hangul is ``\w`` —
|
|
595
|
+
# use ASCII lookarounds so type keywords match with or without a particle.
|
|
596
|
+
_HTML_HINT_RE = re.compile(
|
|
597
|
+
r"(?<![a-z0-9])html(?![a-z0-9])"
|
|
598
|
+
r"|웹\s*페이지|웹페이지|홈페이지|웹\s*사이트|웹사이트|website|web\s*page|landing\s*page",
|
|
599
|
+
)
|
|
600
|
+
_CSS_HINT_RE = re.compile(r"(?<![a-z0-9])css(?![a-z0-9])|스타일\s*시트|stylesheet")
|
|
601
|
+
_JS_HINT_RE = re.compile(
|
|
602
|
+
r"(?<![a-z0-9])js(?![a-z0-9])|javascript|자바스크립트|자바\s*스크립트"
|
|
603
|
+
)
|
|
604
|
+
# An explicit filename means the user is managing paths — keep the
|
|
605
|
+
# deterministic single-file flow untouched.
|
|
606
|
+
_EXPLICIT_FILENAME_RE = re.compile(
|
|
607
|
+
r"[\w-]+\.(?:html?|css|js|jsx|ts|tsx|py|json|md|txt|csv|vue|svelte)\b",
|
|
608
|
+
re.IGNORECASE,
|
|
609
|
+
)
|
|
610
|
+
_PROJECT_NAME_RE = re.compile(r"([A-Za-z][A-Za-z0-9_-]{1,30})\s*(?:앱|app\b)", re.IGNORECASE)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def infer_project_manifest(message: str) -> Optional[Dict[str, Any]]:
|
|
614
|
+
"""Infer a multi-file web-project manifest from a creation request.
|
|
615
|
+
|
|
616
|
+
"todo 앱 html+css+js로 만들어줘" should yield real linked files, not one
|
|
617
|
+
inlined page. Deliberately narrow and deterministic (weak local models
|
|
618
|
+
never see this decision): requires a creation verb, an HTML-page intent,
|
|
619
|
+
at least one *additional* named technology (css/js), and no explicit
|
|
620
|
+
filename. Single-type requests return ``None`` so the existing
|
|
621
|
+
single-file flow is completely unchanged.
|
|
622
|
+
"""
|
|
623
|
+
text = (message or "").strip()
|
|
624
|
+
if not text or not _CREATE_VERB_RE.search(text):
|
|
625
|
+
return None
|
|
626
|
+
if _EXPLICIT_FILENAME_RE.search(text):
|
|
627
|
+
return None
|
|
628
|
+
lower = text.lower()
|
|
629
|
+
wants_html = bool(_HTML_HINT_RE.search(lower))
|
|
630
|
+
wants_css = bool(_CSS_HINT_RE.search(lower))
|
|
631
|
+
wants_js = bool(_JS_HINT_RE.search(lower))
|
|
632
|
+
if not wants_html or not (wants_css or wants_js):
|
|
633
|
+
return None
|
|
634
|
+
|
|
635
|
+
name_match = _PROJECT_NAME_RE.search(text)
|
|
636
|
+
name = f"{name_match.group(1).lower()}-app" if name_match else "web-project"
|
|
637
|
+
|
|
638
|
+
files: List[Dict[str, str]] = []
|
|
639
|
+
html_refs: List[str] = []
|
|
640
|
+
if wants_css:
|
|
641
|
+
html_refs.append('<link rel="stylesheet" href="style.css"> in <head>')
|
|
642
|
+
if wants_js:
|
|
643
|
+
html_refs.append('<script src="app.js"></script> just before </body>')
|
|
644
|
+
files.append({
|
|
645
|
+
"path": "index.html",
|
|
646
|
+
"brief": (
|
|
647
|
+
"The main HTML page of the project. Reference the sibling files: "
|
|
648
|
+
+ " and ".join(html_refs)
|
|
649
|
+
+ ". Do not inline styles or behavior scripts."
|
|
650
|
+
),
|
|
651
|
+
})
|
|
652
|
+
if wants_css:
|
|
653
|
+
files.append({
|
|
654
|
+
"path": "style.css",
|
|
655
|
+
"brief": "All visual styles for index.html (layout, colors, typography).",
|
|
656
|
+
})
|
|
657
|
+
if wants_js:
|
|
658
|
+
files.append({
|
|
659
|
+
"path": "app.js",
|
|
660
|
+
"brief": (
|
|
661
|
+
"All page behavior for index.html as plain browser JavaScript "
|
|
662
|
+
"(no build step, no imports of missing files)."
|
|
663
|
+
),
|
|
664
|
+
})
|
|
665
|
+
return {"name": name, "kind": "web", "files": files}
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
_HTML_LOCAL_REF_RE = re.compile(
|
|
669
|
+
r"(?:href|src)\s*=\s*[\"']([^\"'#?]+)[\"']", re.IGNORECASE
|
|
670
|
+
)
|
|
671
|
+
_EXTERNAL_REF_PREFIXES = ("http://", "https://", "//", "data:", "mailto:", "tel:", "javascript:")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _local_bundle_refs(html: str) -> List[str]:
|
|
675
|
+
"""File references inside an HTML document that must exist in the bundle."""
|
|
676
|
+
refs: List[str] = []
|
|
677
|
+
for ref in _HTML_LOCAL_REF_RE.findall(html or ""):
|
|
678
|
+
candidate = ref.strip()
|
|
679
|
+
if not candidate or candidate.startswith(_EXTERNAL_REF_PREFIXES):
|
|
680
|
+
continue
|
|
681
|
+
if "." not in candidate.rsplit("/", 1)[-1]:
|
|
682
|
+
continue # anchors / routes, not files
|
|
683
|
+
refs.append(candidate)
|
|
684
|
+
return refs
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def repair_bundle_references(files: Dict[str, str]) -> Tuple[Dict[str, str], List[str]]:
|
|
688
|
+
"""Deterministically point dangling HTML refs at real bundle files.
|
|
689
|
+
|
|
690
|
+
A weak model asked for ``style.css`` sometimes links ``styles.css``. When
|
|
691
|
+
a referenced file is missing but the bundle contains exactly one file of
|
|
692
|
+
the same extension, the reference is rewritten. Returns ``(files, fixes)``.
|
|
693
|
+
"""
|
|
694
|
+
names = {p.rsplit("/", 1)[-1] for p in files}
|
|
695
|
+
fixes: List[str] = []
|
|
696
|
+
repaired = dict(files)
|
|
697
|
+
for path, content in files.items():
|
|
698
|
+
if _ext(path) not in (".html", ".htm"):
|
|
699
|
+
continue
|
|
700
|
+
updated = content
|
|
701
|
+
for ref in _local_bundle_refs(content):
|
|
702
|
+
base = ref.rsplit("/", 1)[-1]
|
|
703
|
+
if base in names:
|
|
704
|
+
continue
|
|
705
|
+
same_ext = [n for n in names if _ext(n) == _ext(base)]
|
|
706
|
+
if len(same_ext) == 1:
|
|
707
|
+
updated = updated.replace(ref, same_ext[0])
|
|
708
|
+
fixes.append(f"{path}: '{ref}' -> '{same_ext[0]}'")
|
|
709
|
+
if updated != content:
|
|
710
|
+
repaired[path] = updated
|
|
711
|
+
return repaired, fixes
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def validate_project_bundle(files: Dict[str, str]) -> Dict[str, Any]:
|
|
715
|
+
"""Bundle-level verification: every file valid, every HTML ref resolvable."""
|
|
716
|
+
issues: List[str] = []
|
|
717
|
+
per_file: Dict[str, Dict[str, Any]] = {}
|
|
718
|
+
names = {p.rsplit("/", 1)[-1] for p in files}
|
|
719
|
+
for path, content in files.items():
|
|
720
|
+
ok, reason = validate_file_content(content, path)
|
|
721
|
+
per_file[path] = {"valid": ok, "reason": reason}
|
|
722
|
+
if not ok:
|
|
723
|
+
issues.append(f"{path}: {reason}")
|
|
724
|
+
if _ext(path) in (".html", ".htm"):
|
|
725
|
+
for ref in _local_bundle_refs(content):
|
|
726
|
+
if ref.rsplit("/", 1)[-1] not in names:
|
|
727
|
+
issues.append(f"{path}: references missing file '{ref}'")
|
|
728
|
+
return {"ok": not issues, "issues": issues, "files": per_file}
|
|
729
|
+
|
|
730
|
+
|
|
404
731
|
# ── orchestration ───────────────────────────────────────────────────────
|
|
405
732
|
|
|
406
733
|
async def generate_file_content(
|
|
@@ -409,6 +736,7 @@ async def generate_file_content(
|
|
|
409
736
|
target_path: str,
|
|
410
737
|
user_request: str,
|
|
411
738
|
max_attempts: int = 2,
|
|
739
|
+
bundle_files: Optional[List[str]] = None,
|
|
412
740
|
) -> Tuple[str, Dict[str, Any]]:
|
|
413
741
|
"""Generate validated file content with any LLM.
|
|
414
742
|
|
|
@@ -421,7 +749,9 @@ async def generate_file_content(
|
|
|
421
749
|
feedback: Optional[str] = None
|
|
422
750
|
last_candidate = ""
|
|
423
751
|
for attempt in range(1, max_attempts + 1):
|
|
424
|
-
context = build_file_generation_context(
|
|
752
|
+
context = build_file_generation_context(
|
|
753
|
+
target_path, user_request, feedback=feedback, bundle_files=bundle_files,
|
|
754
|
+
)
|
|
425
755
|
try:
|
|
426
756
|
raw = await generate(context)
|
|
427
757
|
except Exception as exc: # model backend hiccup — repair still delivers
|
|
@@ -441,11 +771,16 @@ async def generate_file_content(
|
|
|
441
771
|
|
|
442
772
|
|
|
443
773
|
__all__ = [
|
|
774
|
+
"PREVIEWABLE_EXTENSIONS",
|
|
444
775
|
"build_file_generation_context",
|
|
445
776
|
"extract_file_content",
|
|
446
777
|
"generate_file_content",
|
|
447
778
|
"infer_file_target",
|
|
779
|
+
"infer_project_manifest",
|
|
448
780
|
"looks_like_refusal",
|
|
781
|
+
"repair_bundle_references",
|
|
449
782
|
"repair_file_content",
|
|
783
|
+
"sanitize_write_content",
|
|
450
784
|
"validate_file_content",
|
|
785
|
+
"validate_project_bundle",
|
|
451
786
|
]
|
|
@@ -49,7 +49,7 @@ __all__ = [
|
|
|
49
49
|
"remove_skill_directory",
|
|
50
50
|
]
|
|
51
51
|
|
|
52
|
-
WORKSPACE_OS_VERSION = "9.9.
|
|
52
|
+
WORKSPACE_OS_VERSION = "9.9.3"
|
|
53
53
|
|
|
54
54
|
# Workspace types separate single-user Personal workspaces from shared
|
|
55
55
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
@@ -38,6 +38,7 @@ def build_persistence_runtime(
|
|
|
38
38
|
from latticeai.core.workspace_os import WorkspaceOSStore
|
|
39
39
|
from latticeai.services.automation_intelligence import AutomationIntelligenceService
|
|
40
40
|
from latticeai.services.brain_intelligence import BrainIntelligenceService
|
|
41
|
+
from latticeai.services.funnel_metrics import FunnelMetricsService
|
|
41
42
|
from latticeai.services.memory_service import MemoryService
|
|
42
43
|
from latticeai.services.workspace_service import WorkspaceService
|
|
43
44
|
|
|
@@ -70,11 +71,27 @@ def build_persistence_runtime(
|
|
|
70
71
|
store=workspace_os,
|
|
71
72
|
enable_graph=enable_graph,
|
|
72
73
|
)
|
|
74
|
+
# UX funnel metrics (backlog #16): cheap JSON counters under the data dir.
|
|
75
|
+
# The ingestion pipeline's audit seam is wrapped so every successful
|
|
76
|
+
# ingest bumps the funnel (and starts the TTFV clock) without touching
|
|
77
|
+
# lattice_brain internals.
|
|
78
|
+
funnel_metrics = FunnelMetricsService(Path(data_dir) / "funnel_metrics.json")
|
|
79
|
+
|
|
80
|
+
def _funnel_audit(action, detail, user):
|
|
81
|
+
try:
|
|
82
|
+
if action == "kg_ingest":
|
|
83
|
+
funnel_metrics.record_ingest(
|
|
84
|
+
duplicate=bool((detail or {}).get("duplicate"))
|
|
85
|
+
)
|
|
86
|
+
except Exception: # noqa: BLE001 — metrics must never break ingestion
|
|
87
|
+
pass
|
|
88
|
+
audit(action, detail, user)
|
|
89
|
+
|
|
73
90
|
ingestion_pipeline = IngestionPipeline(
|
|
74
91
|
knowledge_graph,
|
|
75
92
|
hooks=hooks_registry,
|
|
76
93
|
enable_graph=enable_graph,
|
|
77
|
-
audit=
|
|
94
|
+
audit=_funnel_audit,
|
|
78
95
|
)
|
|
79
96
|
device_identity = DeviceIdentity(data_dir)
|
|
80
97
|
kg_portability = KGPortabilityService(
|
|
@@ -99,4 +116,5 @@ def build_persistence_runtime(
|
|
|
99
116
|
"INGESTION_PIPELINE": ingestion_pipeline,
|
|
100
117
|
"DEVICE_IDENTITY": device_identity,
|
|
101
118
|
"KG_PORTABILITY": kg_portability,
|
|
119
|
+
"FUNNEL_METRICS": funnel_metrics,
|
|
102
120
|
}
|
|
@@ -605,6 +605,7 @@ def register_review_and_brain_tail_routers(
|
|
|
605
605
|
create_setup_router: Any,
|
|
606
606
|
model_router: Any,
|
|
607
607
|
change_proposals: Any = None,
|
|
608
|
+
knowledge_graph: Any = None,
|
|
608
609
|
) -> Any:
|
|
609
610
|
"""Register the final review/browser/brain tail routes in legacy order."""
|
|
610
611
|
|
|
@@ -644,6 +645,12 @@ def register_review_and_brain_tail_routers(
|
|
|
644
645
|
require_admin=require_admin,
|
|
645
646
|
),
|
|
646
647
|
create_garden_router(gardener=gardener, require_user=require_user),
|
|
647
|
-
create_setup_router(
|
|
648
|
+
create_setup_router(
|
|
649
|
+
model_router=model_router,
|
|
650
|
+
require_user=require_user,
|
|
651
|
+
ingestion_pipeline=ingestion_pipeline,
|
|
652
|
+
knowledge_graph=knowledge_graph,
|
|
653
|
+
workspace_service=workspace_service,
|
|
654
|
+
),
|
|
648
655
|
)
|
|
649
656
|
return brain_network
|