ltcai 9.9.2 → 9.9.4
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 +44 -39
- package/docs/CHANGELOG.md +109 -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/SURFACE_PARITY.md +47 -0
- 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/_kg_common.py +335 -0
- package/lattice_brain/graph/curator.py +141 -0
- package/lattice_brain/graph/discovery_index.py +8 -1
- package/lattice_brain/graph/documents.py +111 -0
- package/lattice_brain/graph/fusion.py +179 -0
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +382 -37
- package/lattice_brain/graph/retrieval.py +71 -4
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/ingestion.py +66 -0
- package/lattice_brain/portability.py +41 -12
- 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 +33 -2
- package/latticeai/api/chat_agent_http.py +363 -6
- package/latticeai/api/chat_contracts.py +7 -1
- package/latticeai/api/chat_helpers.py +147 -0
- package/latticeai/api/chat_intents.py +217 -10
- package/latticeai/api/chat_stream.py +93 -3
- package/latticeai/api/funnel_metrics.py +31 -0
- package/latticeai/api/knowledge_graph.py +65 -1
- 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 +14 -0
- package/latticeai/core/agent.py +355 -18
- package/latticeai/core/agent_eval.py +107 -0
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +404 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/run_store.py +243 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/models/router.py +25 -12
- package/latticeai/runtime/persistence_runtime.py +19 -1
- package/latticeai/runtime/router_registration.py +8 -1
- package/latticeai/services/app_context.py +3 -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 +110 -2
- package/latticeai/services/folder_watch.py +396 -0
- package/latticeai/services/funnel_metrics.py +210 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +51 -5
- package/latticeai/setup/demo_corpus.py +98 -0
- package/latticeai/tools/filesystem.py +38 -1
- package/package.json +1 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/bench_models.py +234 -5
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/funnel_soft_gate.py +192 -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 +29 -27
- package/static/app/assets/Act-BCmTU0E2.js +1 -0
- package/static/app/assets/{Brain-BnGbytHa.js → Brain-CTnjox7w.js} +2 -2
- package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
- package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
- package/static/app/assets/{Capture-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
- package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
- package/static/app/assets/Library-pKCK0_tk.js +1 -0
- package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
- package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
- package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
- package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
- package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
- package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
- package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
- package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
- package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
- package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
- package/static/app/assets/index-7FAfYm4v.css +2 -0
- package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
- package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
- package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
- package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
- package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
- package/static/app/assets/shield-alert-BftATuAA.js +1 -0
- package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
- package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
- package/static/app/assets/utils-SJUNVOj5.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-Cx_gZNbE.js +0 -1
- package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
- package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
- package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
- package/static/app/assets/Library-PcfVUco6.js +0 -1
- package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
- package/static/app/assets/download-C7yxYHvz.js +0 -1
- package/static/app/assets/index-DU78Kogz.css +0 -2
- package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
- package/static/app/assets/utils-Dh-Dwtkb.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)} != "
|
|
@@ -52,6 +52,11 @@ class EnterpriseCapability(str, Enum):
|
|
|
52
52
|
DLP_POLICY = "dlp_policy"
|
|
53
53
|
EDISCOVERY = "ediscovery"
|
|
54
54
|
ADMIN_POLICY_PACKS = "admin_policy_packs"
|
|
55
|
+
# Human review-before-promote governance for knowledge-graph concept
|
|
56
|
+
# promotions (review 2026-07-25 Wave 4). Community users can opt in per
|
|
57
|
+
# deployment via LATTICEAI_GRAPH_PROMOTION_REVIEW; this capability is the
|
|
58
|
+
# seam for Enterprise policy packs that mandate reviewed promotions.
|
|
59
|
+
GRAPH_PROMOTION_REVIEW = "graph_promotion_review"
|
|
55
60
|
|
|
56
61
|
|
|
57
62
|
@runtime_checkable
|
|
@@ -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():
|
|
@@ -211,10 +284,28 @@ def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
|
|
|
211
284
|
return False, f"invalid JSON: {exc}"
|
|
212
285
|
return True, "ok"
|
|
213
286
|
if ext == ".css":
|
|
287
|
+
if "```" in content:
|
|
288
|
+
return False, "output still contains Markdown fences"
|
|
214
289
|
if "{" not in content or "}" not in content:
|
|
215
290
|
return False, "no CSS rule blocks found"
|
|
216
291
|
return True, "ok"
|
|
217
|
-
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"):
|
|
218
309
|
if "```" in content:
|
|
219
310
|
return False, "output still contains Markdown fences"
|
|
220
311
|
return True, "ok"
|
|
@@ -248,13 +339,48 @@ _TYPE_RULES: Dict[str, str] = {
|
|
|
248
339
|
".csv": "Produce CSV with a header row; comma-separated, one record per line.",
|
|
249
340
|
".py": "Produce complete runnable Python source code.",
|
|
250
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.",
|
|
251
347
|
}
|
|
252
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
|
+
|
|
361
|
+
# Vite/React bundles need a module entry point, not classic script tags.
|
|
362
|
+
_BUNDLE_HTML_MODULE_RULE = (
|
|
363
|
+
"Produce ONE complete HTML5 document: <!DOCTYPE html>, <html>, <head> with "
|
|
364
|
+
"<meta charset=\"utf-8\"> and a <title>, and a closed </html> tag. "
|
|
365
|
+
"This page is the Vite entry of a React project: the <body> must contain "
|
|
366
|
+
"<div id=\"root\"></div> and load the app with "
|
|
367
|
+
"<script type=\"module\" src=\"/src/main.jsx\"></script> just before "
|
|
368
|
+
"</body>. No inline <style> blocks, no other scripts, no external files."
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _bundle_html_rule(bundle_files: List[str]) -> str:
|
|
373
|
+
"""Pick the HTML bundle rule that matches the bundle's technology."""
|
|
374
|
+
if any(str(path).lower().endswith((".jsx", ".tsx")) for path in bundle_files):
|
|
375
|
+
return _BUNDLE_HTML_MODULE_RULE
|
|
376
|
+
return _BUNDLE_HTML_RULE
|
|
377
|
+
|
|
253
378
|
|
|
254
379
|
def build_file_generation_context(
|
|
255
380
|
target_path: str,
|
|
256
381
|
user_request: str,
|
|
257
382
|
feedback: Optional[str] = None,
|
|
383
|
+
bundle_files: Optional[List[str]] = None,
|
|
258
384
|
) -> str:
|
|
259
385
|
"""Strict, extension-aware generation instructions.
|
|
260
386
|
|
|
@@ -271,8 +397,13 @@ def build_file_generation_context(
|
|
|
271
397
|
"no text before or after the content.",
|
|
272
398
|
]
|
|
273
399
|
type_rule = _TYPE_RULES.get(ext)
|
|
400
|
+
if bundle_files and ext in (".html", ".htm"):
|
|
401
|
+
type_rule = _bundle_html_rule(bundle_files)
|
|
274
402
|
if type_rule:
|
|
275
403
|
parts.append(f"- {type_rule}")
|
|
404
|
+
if bundle_files:
|
|
405
|
+
listed = ", ".join(bundle_files)
|
|
406
|
+
parts.append(f"- Project files in this bundle: {listed}")
|
|
276
407
|
first_line = _FIRST_LINE_HINTS.get(ext)
|
|
277
408
|
if first_line:
|
|
278
409
|
parts.append(f"- The very first line of your reply must be: {first_line}")
|
|
@@ -309,12 +440,29 @@ def repair_file_content(content: str, target_path: str, user_request: str) -> st
|
|
|
309
440
|
ensure_ascii=False,
|
|
310
441
|
indent=2,
|
|
311
442
|
)
|
|
443
|
+
if ext == ".py" and salvage:
|
|
444
|
+
# The repair guarantee for Python is parseability: unparseable output
|
|
445
|
+
# is preserved honestly as a commented-out draft, never as a broken
|
|
446
|
+
# module the user has to debug.
|
|
447
|
+
try:
|
|
448
|
+
ast.parse(salvage)
|
|
449
|
+
return salvage
|
|
450
|
+
except SyntaxError:
|
|
451
|
+
commented = "\n".join(f"# {line}" for line in salvage.splitlines())
|
|
452
|
+
return (
|
|
453
|
+
f"# TODO: model produced invalid Python for: {user_request}\n"
|
|
454
|
+
"# The draft below is preserved as comments — fix and uncomment.\n"
|
|
455
|
+
f"{commented}\n"
|
|
456
|
+
)
|
|
312
457
|
if salvage:
|
|
313
458
|
return salvage
|
|
314
459
|
# Nothing usable at all — leave an honest placeholder in the right format.
|
|
315
460
|
comment = {
|
|
316
461
|
".py": "# TODO: model produced no usable content for: ",
|
|
317
462
|
".js": "// TODO: model produced no usable content for: ",
|
|
463
|
+
".jsx": "// TODO: model produced no usable content for: ",
|
|
464
|
+
".ts": "// TODO: model produced no usable content for: ",
|
|
465
|
+
".tsx": "// TODO: model produced no usable content for: ",
|
|
318
466
|
".css": "/* TODO: model produced no usable content for: ",
|
|
319
467
|
".sh": "# TODO: model produced no usable content for: ",
|
|
320
468
|
".sql": "-- TODO: model produced no usable content for: ",
|
|
@@ -371,6 +519,7 @@ def _repair_html(salvage: str, user_request: str) -> str:
|
|
|
371
519
|
PREVIEWABLE_EXTENSIONS = frozenset({
|
|
372
520
|
".html", ".htm", ".md", ".markdown", ".txt", ".json", ".css", ".js",
|
|
373
521
|
".csv", ".py", ".yaml", ".yml", ".xml", ".sql", ".sh",
|
|
522
|
+
".jsx", ".ts", ".tsx", ".vue", ".svelte",
|
|
374
523
|
})
|
|
375
524
|
|
|
376
525
|
|
|
@@ -457,6 +606,253 @@ def infer_file_target(message: str) -> Optional[str]:
|
|
|
457
606
|
return None
|
|
458
607
|
|
|
459
608
|
|
|
609
|
+
# ── project manifest (multi-file bundles) ───────────────────────────────
|
|
610
|
+
|
|
611
|
+
# ``\b`` fails against Korean particles ("js로") because Hangul is ``\w`` —
|
|
612
|
+
# use ASCII lookarounds so type keywords match with or without a particle.
|
|
613
|
+
_HTML_HINT_RE = re.compile(
|
|
614
|
+
r"(?<![a-z0-9])html(?![a-z0-9])"
|
|
615
|
+
r"|웹\s*페이지|웹페이지|홈페이지|웹\s*사이트|웹사이트|website|web\s*page|landing\s*page",
|
|
616
|
+
)
|
|
617
|
+
_CSS_HINT_RE = re.compile(r"(?<![a-z0-9])css(?![a-z0-9])|스타일\s*시트|stylesheet")
|
|
618
|
+
_JS_HINT_RE = re.compile(
|
|
619
|
+
r"(?<![a-z0-9])js(?![a-z0-9])|javascript|자바스크립트|자바\s*스크립트"
|
|
620
|
+
)
|
|
621
|
+
# An explicit filename means the user is managing paths — keep the
|
|
622
|
+
# deterministic single-file flow untouched.
|
|
623
|
+
_EXPLICIT_FILENAME_RE = re.compile(
|
|
624
|
+
r"[\w-]+\.(?:html?|css|js|jsx|ts|tsx|py|json|md|txt|csv|vue|svelte)\b",
|
|
625
|
+
re.IGNORECASE,
|
|
626
|
+
)
|
|
627
|
+
_PROJECT_NAME_RE = re.compile(r"([A-Za-z][A-Za-z0-9_-]{1,30})\s*(?:앱|app\b)", re.IGNORECASE)
|
|
628
|
+
# React/Vite intent: the react keyword is specific enough on its own.
|
|
629
|
+
_REACT_HINT_RE = re.compile(r"(?<![a-z0-9])react(?![a-z0-9])|리액트")
|
|
630
|
+
_VITE_HINT_RE = re.compile(r"(?<![a-z0-9])vite(?![a-z0-9])")
|
|
631
|
+
# Python package intent: language + package word, both required.
|
|
632
|
+
_PYTHON_HINT_RE = re.compile(r"(?<![a-z0-9])python(?![a-z0-9])|파이썬")
|
|
633
|
+
_PACKAGE_HINT_RE = re.compile(r"패키지|(?<![a-z0-9])package(?![a-z0-9])")
|
|
634
|
+
_PKG_NAME_RE = re.compile(
|
|
635
|
+
r"([A-Za-z][A-Za-z0-9_-]{1,30})\s*(?:패키지|package\b)", re.IGNORECASE
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _react_manifest(text: str) -> Dict[str, Any]:
|
|
640
|
+
"""Vite + React starter manifest (review Wave 4: manifest 확장)."""
|
|
641
|
+
name_match = _PROJECT_NAME_RE.search(text)
|
|
642
|
+
name = f"{name_match.group(1).lower()}-app" if name_match else "react-app"
|
|
643
|
+
return {
|
|
644
|
+
"name": name,
|
|
645
|
+
"kind": "react",
|
|
646
|
+
"files": [
|
|
647
|
+
{
|
|
648
|
+
"path": "package.json",
|
|
649
|
+
"brief": (
|
|
650
|
+
f'Vite React app manifest: strictly valid JSON with "name": "{name}", '
|
|
651
|
+
'"private": true, "type": "module", "scripts" {"dev": "vite", '
|
|
652
|
+
'"build": "vite build", "preview": "vite preview"}, "dependencies" '
|
|
653
|
+
'with react and react-dom (^18), and "devDependencies" with vite '
|
|
654
|
+
"and @vitejs/plugin-react."
|
|
655
|
+
),
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
"path": "index.html",
|
|
659
|
+
"brief": (
|
|
660
|
+
"The Vite entry HTML: <div id=\"root\"></div> in <body> and "
|
|
661
|
+
"<script type=\"module\" src=\"/src/main.jsx\"></script> just "
|
|
662
|
+
"before </body>. No inline styles or scripts."
|
|
663
|
+
),
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
"path": "src/main.jsx",
|
|
667
|
+
"brief": (
|
|
668
|
+
"React entry: createRoot from react-dom/client rendering <App /> "
|
|
669
|
+
"into #root; imports ./App.jsx and ./App.css."
|
|
670
|
+
),
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
"path": "src/App.jsx",
|
|
674
|
+
"brief": (
|
|
675
|
+
"The main App component implementing the user's request as one "
|
|
676
|
+
"self-contained React component (hooks allowed, no extra deps)."
|
|
677
|
+
),
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
"path": "src/App.css",
|
|
681
|
+
"brief": "All visual styles for the App component.",
|
|
682
|
+
},
|
|
683
|
+
],
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _python_package_manifest(text: str) -> Dict[str, Any]:
|
|
688
|
+
"""Multi-file Python package manifest (review Wave 4: manifest 확장)."""
|
|
689
|
+
name_match = _PKG_NAME_RE.search(text)
|
|
690
|
+
raw_name = name_match.group(1).lower() if name_match else "my_package"
|
|
691
|
+
module = re.sub(r"[^a-z0-9_]", "_", raw_name)
|
|
692
|
+
if not re.match(r"[a-z_]", module):
|
|
693
|
+
module = f"pkg_{module}"
|
|
694
|
+
return {
|
|
695
|
+
"name": module,
|
|
696
|
+
"kind": "python",
|
|
697
|
+
"files": [
|
|
698
|
+
{
|
|
699
|
+
"path": f"{module}/__init__.py",
|
|
700
|
+
"brief": (
|
|
701
|
+
f"Package init for {module}: import and re-export the public "
|
|
702
|
+
"API from .core with an explicit __all__."
|
|
703
|
+
),
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
"path": f"{module}/core.py",
|
|
707
|
+
"brief": (
|
|
708
|
+
"Implement the user's request as clean, documented functions/"
|
|
709
|
+
"classes with type hints. Standard library only."
|
|
710
|
+
),
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
"path": f"{module}/cli.py",
|
|
714
|
+
"brief": (
|
|
715
|
+
"argparse CLI wrapping the core API: a main() function and an "
|
|
716
|
+
'if __name__ == "__main__": main() guard.'
|
|
717
|
+
),
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
"path": "README.md",
|
|
721
|
+
"brief": (
|
|
722
|
+
f"Usage documentation for the {module} package: install, import "
|
|
723
|
+
"example, and CLI example."
|
|
724
|
+
),
|
|
725
|
+
},
|
|
726
|
+
],
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def infer_project_manifest(message: str) -> Optional[Dict[str, Any]]:
|
|
731
|
+
"""Infer a multi-file project manifest from a creation request.
|
|
732
|
+
|
|
733
|
+
"todo 앱 html+css+js로 만들어줘" should yield real linked files, not one
|
|
734
|
+
inlined page. Deliberately narrow and deterministic (weak local models
|
|
735
|
+
never see this decision): requires a creation verb, a recognized project
|
|
736
|
+
intent (web page + css/js, React/Vite app, or Python package), and no
|
|
737
|
+
explicit filename. Single-type requests return ``None`` so the existing
|
|
738
|
+
single-file flow is completely unchanged.
|
|
739
|
+
"""
|
|
740
|
+
text = (message or "").strip()
|
|
741
|
+
if not text or not _CREATE_VERB_RE.search(text):
|
|
742
|
+
return None
|
|
743
|
+
if _EXPLICIT_FILENAME_RE.search(text):
|
|
744
|
+
return None
|
|
745
|
+
lower = text.lower()
|
|
746
|
+
|
|
747
|
+
# Most-specific first: React (its own structure), then Python package,
|
|
748
|
+
# then the classic html+css/js web bundle.
|
|
749
|
+
if _REACT_HINT_RE.search(lower) or _VITE_HINT_RE.search(lower):
|
|
750
|
+
return _react_manifest(text)
|
|
751
|
+
if _PYTHON_HINT_RE.search(lower) and _PACKAGE_HINT_RE.search(lower):
|
|
752
|
+
return _python_package_manifest(text)
|
|
753
|
+
|
|
754
|
+
wants_html = bool(_HTML_HINT_RE.search(lower))
|
|
755
|
+
wants_css = bool(_CSS_HINT_RE.search(lower))
|
|
756
|
+
wants_js = bool(_JS_HINT_RE.search(lower))
|
|
757
|
+
if not wants_html or not (wants_css or wants_js):
|
|
758
|
+
return None
|
|
759
|
+
|
|
760
|
+
name_match = _PROJECT_NAME_RE.search(text)
|
|
761
|
+
name = f"{name_match.group(1).lower()}-app" if name_match else "web-project"
|
|
762
|
+
|
|
763
|
+
files: List[Dict[str, str]] = []
|
|
764
|
+
html_refs: List[str] = []
|
|
765
|
+
if wants_css:
|
|
766
|
+
html_refs.append('<link rel="stylesheet" href="style.css"> in <head>')
|
|
767
|
+
if wants_js:
|
|
768
|
+
html_refs.append('<script src="app.js"></script> just before </body>')
|
|
769
|
+
files.append({
|
|
770
|
+
"path": "index.html",
|
|
771
|
+
"brief": (
|
|
772
|
+
"The main HTML page of the project. Reference the sibling files: "
|
|
773
|
+
+ " and ".join(html_refs)
|
|
774
|
+
+ ". Do not inline styles or behavior scripts."
|
|
775
|
+
),
|
|
776
|
+
})
|
|
777
|
+
if wants_css:
|
|
778
|
+
files.append({
|
|
779
|
+
"path": "style.css",
|
|
780
|
+
"brief": "All visual styles for index.html (layout, colors, typography).",
|
|
781
|
+
})
|
|
782
|
+
if wants_js:
|
|
783
|
+
files.append({
|
|
784
|
+
"path": "app.js",
|
|
785
|
+
"brief": (
|
|
786
|
+
"All page behavior for index.html as plain browser JavaScript "
|
|
787
|
+
"(no build step, no imports of missing files)."
|
|
788
|
+
),
|
|
789
|
+
})
|
|
790
|
+
return {"name": name, "kind": "web", "files": files}
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
_HTML_LOCAL_REF_RE = re.compile(
|
|
794
|
+
r"(?:href|src)\s*=\s*[\"']([^\"'#?]+)[\"']", re.IGNORECASE
|
|
795
|
+
)
|
|
796
|
+
_EXTERNAL_REF_PREFIXES = ("http://", "https://", "//", "data:", "mailto:", "tel:", "javascript:")
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _local_bundle_refs(html: str) -> List[str]:
|
|
800
|
+
"""File references inside an HTML document that must exist in the bundle."""
|
|
801
|
+
refs: List[str] = []
|
|
802
|
+
for ref in _HTML_LOCAL_REF_RE.findall(html or ""):
|
|
803
|
+
candidate = ref.strip()
|
|
804
|
+
if not candidate or candidate.startswith(_EXTERNAL_REF_PREFIXES):
|
|
805
|
+
continue
|
|
806
|
+
if "." not in candidate.rsplit("/", 1)[-1]:
|
|
807
|
+
continue # anchors / routes, not files
|
|
808
|
+
refs.append(candidate)
|
|
809
|
+
return refs
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def repair_bundle_references(files: Dict[str, str]) -> Tuple[Dict[str, str], List[str]]:
|
|
813
|
+
"""Deterministically point dangling HTML refs at real bundle files.
|
|
814
|
+
|
|
815
|
+
A weak model asked for ``style.css`` sometimes links ``styles.css``. When
|
|
816
|
+
a referenced file is missing but the bundle contains exactly one file of
|
|
817
|
+
the same extension, the reference is rewritten. Returns ``(files, fixes)``.
|
|
818
|
+
"""
|
|
819
|
+
names = {p.rsplit("/", 1)[-1] for p in files}
|
|
820
|
+
fixes: List[str] = []
|
|
821
|
+
repaired = dict(files)
|
|
822
|
+
for path, content in files.items():
|
|
823
|
+
if _ext(path) not in (".html", ".htm"):
|
|
824
|
+
continue
|
|
825
|
+
updated = content
|
|
826
|
+
for ref in _local_bundle_refs(content):
|
|
827
|
+
base = ref.rsplit("/", 1)[-1]
|
|
828
|
+
if base in names:
|
|
829
|
+
continue
|
|
830
|
+
same_ext = [n for n in names if _ext(n) == _ext(base)]
|
|
831
|
+
if len(same_ext) == 1:
|
|
832
|
+
updated = updated.replace(ref, same_ext[0])
|
|
833
|
+
fixes.append(f"{path}: '{ref}' -> '{same_ext[0]}'")
|
|
834
|
+
if updated != content:
|
|
835
|
+
repaired[path] = updated
|
|
836
|
+
return repaired, fixes
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def validate_project_bundle(files: Dict[str, str]) -> Dict[str, Any]:
|
|
840
|
+
"""Bundle-level verification: every file valid, every HTML ref resolvable."""
|
|
841
|
+
issues: List[str] = []
|
|
842
|
+
per_file: Dict[str, Dict[str, Any]] = {}
|
|
843
|
+
names = {p.rsplit("/", 1)[-1] for p in files}
|
|
844
|
+
for path, content in files.items():
|
|
845
|
+
ok, reason = validate_file_content(content, path)
|
|
846
|
+
per_file[path] = {"valid": ok, "reason": reason}
|
|
847
|
+
if not ok:
|
|
848
|
+
issues.append(f"{path}: {reason}")
|
|
849
|
+
if _ext(path) in (".html", ".htm"):
|
|
850
|
+
for ref in _local_bundle_refs(content):
|
|
851
|
+
if ref.rsplit("/", 1)[-1] not in names:
|
|
852
|
+
issues.append(f"{path}: references missing file '{ref}'")
|
|
853
|
+
return {"ok": not issues, "issues": issues, "files": per_file}
|
|
854
|
+
|
|
855
|
+
|
|
460
856
|
# ── orchestration ───────────────────────────────────────────────────────
|
|
461
857
|
|
|
462
858
|
async def generate_file_content(
|
|
@@ -465,6 +861,7 @@ async def generate_file_content(
|
|
|
465
861
|
target_path: str,
|
|
466
862
|
user_request: str,
|
|
467
863
|
max_attempts: int = 2,
|
|
864
|
+
bundle_files: Optional[List[str]] = None,
|
|
468
865
|
) -> Tuple[str, Dict[str, Any]]:
|
|
469
866
|
"""Generate validated file content with any LLM.
|
|
470
867
|
|
|
@@ -477,7 +874,9 @@ async def generate_file_content(
|
|
|
477
874
|
feedback: Optional[str] = None
|
|
478
875
|
last_candidate = ""
|
|
479
876
|
for attempt in range(1, max_attempts + 1):
|
|
480
|
-
context = build_file_generation_context(
|
|
877
|
+
context = build_file_generation_context(
|
|
878
|
+
target_path, user_request, feedback=feedback, bundle_files=bundle_files,
|
|
879
|
+
)
|
|
481
880
|
try:
|
|
482
881
|
raw = await generate(context)
|
|
483
882
|
except Exception as exc: # model backend hiccup — repair still delivers
|
|
@@ -502,8 +901,11 @@ __all__ = [
|
|
|
502
901
|
"extract_file_content",
|
|
503
902
|
"generate_file_content",
|
|
504
903
|
"infer_file_target",
|
|
904
|
+
"infer_project_manifest",
|
|
505
905
|
"looks_like_refusal",
|
|
906
|
+
"repair_bundle_references",
|
|
506
907
|
"repair_file_content",
|
|
507
908
|
"sanitize_write_content",
|
|
508
909
|
"validate_file_content",
|
|
910
|
+
"validate_project_bundle",
|
|
509
911
|
]
|