ltcai 11.2.0 → 11.4.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 +46 -53
- package/docs/CHANGELOG.md +61 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/MULTI_AGENT_RUNTIME.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +6 -2
- package/docs/PERMISSION_MODE.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +2 -2
- package/docs/v11.3.0_PLAN.md +202 -0
- package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common/__init__.py +287 -0
- package/lattice_brain/graph/_kg_common/extraction.py +516 -0
- package/lattice_brain/graph/_kg_common/relations.py +161 -0
- package/lattice_brain/graph/_kg_common/text.py +479 -0
- package/lattice_brain/graph/discovery_index/__init__.py +35 -0
- package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
- package/lattice_brain/graph/discovery_index/extract.py +137 -0
- package/lattice_brain/graph/discovery_index/scan.py +411 -0
- package/lattice_brain/graph/discovery_index/upsert.py +495 -0
- package/lattice_brain/graph/projection/__init__.py +42 -0
- package/lattice_brain/graph/projection/curation.py +500 -0
- package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
- package/lattice_brain/graph/retrieval/__init__.py +54 -0
- package/lattice_brain/graph/retrieval/context.py +197 -0
- package/lattice_brain/graph/retrieval/graph_view.py +319 -0
- package/lattice_brain/graph/retrieval/hybrid.py +488 -0
- package/lattice_brain/graph/retrieval/maintenance.py +121 -0
- package/lattice_brain/graph/retrieval/signals.py +95 -0
- package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
- package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
- package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
- package/lattice_brain/graph/retrieval_vector/search.py +560 -0
- package/lattice_brain/graph/retrieval_vector/status.py +374 -0
- package/lattice_brain/ingestion/__init__.py +130 -0
- package/lattice_brain/ingestion/_contract.py +90 -0
- package/lattice_brain/ingestion/constants.py +127 -0
- package/lattice_brain/ingestion/folder_scan.py +57 -0
- package/lattice_brain/ingestion/folders.py +258 -0
- package/lattice_brain/ingestion/hashing.py +26 -0
- package/lattice_brain/ingestion/jobs_api.py +107 -0
- package/lattice_brain/ingestion/models.py +80 -0
- package/lattice_brain/ingestion/pipeline.py +486 -0
- package/lattice_brain/ingestion/quality.py +209 -0
- package/lattice_brain/ingestion/routing.py +295 -0
- package/lattice_brain/multimodal/__init__.py +164 -0
- package/lattice_brain/multimodal/audio.py +77 -0
- package/lattice_brain/multimodal/common.py +118 -0
- package/lattice_brain/multimodal/images.py +498 -0
- package/lattice_brain/multimodal/ports.py +169 -0
- package/lattice_brain/multimodal/video.py +410 -0
- package/lattice_brain/portability/__init__.py +90 -0
- package/lattice_brain/portability/_contract.py +42 -0
- package/lattice_brain/portability/backups.py +338 -0
- package/lattice_brain/portability/bundles.py +136 -0
- package/lattice_brain/portability/constants.py +93 -0
- package/lattice_brain/portability/fsops.py +138 -0
- package/lattice_brain/portability/service.py +41 -0
- package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
- package/lattice_brain/runtime/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chronicle.py +63 -0
- package/latticeai/core/agent/__init__.py +93 -0
- package/latticeai/core/agent/_contract.py +79 -0
- package/latticeai/core/agent/context.py +57 -0
- package/latticeai/core/agent/deps.py +125 -0
- package/latticeai/core/agent/execution.py +622 -0
- package/latticeai/core/agent/planning.py +145 -0
- package/latticeai/core/agent/recovery.py +157 -0
- package/latticeai/core/agent/runtime.py +210 -0
- package/latticeai/core/agent/verification.py +231 -0
- package/latticeai/core/embedding_providers/__init__.py +151 -0
- package/latticeai/core/embedding_providers/base.py +199 -0
- package/latticeai/core/embedding_providers/captions.py +162 -0
- package/latticeai/core/embedding_providers/profiles.py +126 -0
- package/latticeai/core/embedding_providers/text.py +350 -0
- package/latticeai/core/embedding_providers/vision.py +352 -0
- package/latticeai/core/file_generation/__init__.py +115 -0
- package/latticeai/core/file_generation/bundles.py +76 -0
- package/latticeai/core/file_generation/extraction.py +154 -0
- package/latticeai/core/file_generation/inference.py +235 -0
- package/latticeai/core/file_generation/orchestration.py +152 -0
- package/latticeai/core/file_generation/prompting.py +117 -0
- package/latticeai/core/file_generation/repair.py +114 -0
- package/latticeai/core/file_generation/sanitize.py +61 -0
- package/latticeai/core/file_generation/validation.py +201 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +9 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/integrations/telegram_bot/__init__.py +123 -0
- package/latticeai/integrations/telegram_bot/__main__.py +17 -0
- package/latticeai/integrations/telegram_bot/config.py +86 -0
- package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
- package/latticeai/integrations/telegram_bot/flows.py +478 -0
- package/latticeai/integrations/telegram_bot/helpers.py +322 -0
- package/latticeai/integrations/telegram_bot/screens.py +394 -0
- package/latticeai/models/router/__init__.py +88 -0
- package/latticeai/models/router/_contract.py +66 -0
- package/latticeai/models/router/branding.py +56 -0
- package/latticeai/models/router/catalog.py +69 -0
- package/latticeai/models/router/documents.py +199 -0
- package/latticeai/models/router/errors.py +37 -0
- package/latticeai/models/router/generation.py +258 -0
- package/latticeai/models/router/loading.py +291 -0
- package/latticeai/models/router/local_models.py +85 -0
- package/latticeai/models/router/registry.py +147 -0
- package/latticeai/runtime/build_phases/__init__.py +82 -0
- package/latticeai/runtime/build_phases/features.py +407 -0
- package/latticeai/runtime/build_phases/foundation.py +555 -0
- package/latticeai/runtime/build_phases/web.py +492 -0
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +48 -19
- package/latticeai/services/brain_intelligence/__init__.py +58 -0
- package/latticeai/services/brain_intelligence/_contract.py +71 -0
- package/latticeai/services/brain_intelligence/consistency.py +193 -0
- package/latticeai/services/brain_intelligence/constants.py +47 -0
- package/latticeai/services/brain_intelligence/digest.py +258 -0
- package/latticeai/services/brain_intelligence/health.py +331 -0
- package/latticeai/services/brain_intelligence/proposals.py +264 -0
- package/latticeai/services/brain_intelligence/sampling.py +84 -0
- package/latticeai/services/brain_intelligence/service.py +48 -0
- package/latticeai/services/chronicle.py +557 -0
- package/latticeai/services/memory_service/__init__.py +52 -0
- package/latticeai/services/memory_service/_contract.py +100 -0
- package/latticeai/services/memory_service/brief.py +431 -0
- package/latticeai/services/memory_service/constants.py +57 -0
- package/latticeai/services/memory_service/maintenance.py +138 -0
- package/latticeai/services/memory_service/manager.py +186 -0
- package/latticeai/services/memory_service/proof.py +136 -0
- package/latticeai/services/memory_service/recall.py +225 -0
- package/latticeai/services/memory_service/service.py +48 -0
- package/latticeai/services/memory_service/stores.py +110 -0
- package/latticeai/services/model_runtime/__init__.py +322 -0
- package/latticeai/services/model_runtime/cloud.py +87 -0
- package/latticeai/services/model_runtime/download.py +282 -0
- package/latticeai/services/model_runtime/engines.py +341 -0
- package/latticeai/services/model_runtime/loading.py +178 -0
- package/latticeai/services/model_runtime/service.py +129 -0
- package/latticeai/services/model_runtime/state.py +131 -0
- package/latticeai/services/model_runtime/status.py +255 -0
- package/latticeai/services/product_readiness.py +15 -7
- package/latticeai/setup/wizard/__init__.py +126 -0
- package/latticeai/setup/wizard/catalog.py +172 -0
- package/latticeai/setup/wizard/detect.py +323 -0
- package/latticeai/setup/wizard/install.py +348 -0
- package/latticeai/setup/wizard/paths.py +168 -0
- package/latticeai/setup/wizard/plans.py +74 -0
- package/latticeai/setup/wizard/recommend.py +320 -0
- package/package.json +6 -2
- package/scripts/bump_version.py +14 -0
- package/scripts/capture_release_evidence.mjs +33 -21
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +41 -4
- package/scripts/check_max_file_lines.mjs +102 -0
- package/scripts/check_release_evidence_bound.mjs +30 -15
- package/scripts/check_screenshot_pixel_delta.py +34 -4
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/generate_rust_parity_fixtures.py +562 -0
- package/scripts/lib/mock_server_fingerprint.mjs +94 -0
- package/scripts/release_screen_claims.json +31 -2
- package/src-tauri/Cargo.lock +361 -3
- package/src-tauri/Cargo.toml +6 -1
- package/src-tauri/src/backend.rs +349 -0
- package/src-tauri/src/folder.rs +33 -0
- package/src-tauri/src/main.rs +97 -399
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +41 -37
- package/static/app/assets/Act-yYpYnn0v.js +1 -0
- package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
- package/static/app/assets/{Brain-tuhI4sOC.js → Brain-C1HBN0Wf.js} +2 -2
- package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
- package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
- package/static/app/assets/Capture-CFIRsFNE.js +1 -0
- package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
- package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
- package/static/app/assets/Library-DwO3yZST.js +1 -0
- package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-Jn1GK0-S.js} +1 -1
- package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
- package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
- package/static/app/assets/System-DW8F-2xL.js +1 -0
- package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
- package/static/app/assets/{bot-Cia42c2h.js → bot-IM_E_Y12.js} +1 -1
- package/static/app/assets/brain-Ci1CkWjM.js +1 -0
- package/static/app/assets/{button-2j2Ijzgq.js → button-COwyqfHM.js} +1 -1
- package/static/app/assets/circle-check-DfInj-qD.js +1 -0
- package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-DEM4A1Y5.js} +1 -1
- package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-C9djDuLd.js} +1 -1
- package/static/app/assets/{cpu-k4awryFq.js → cpu-DFdo1gw-.js} +1 -1
- package/static/app/assets/{download-DFbLJ_ig.js → download-SnJL6oqk.js} +1 -1
- package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CqZeDkjE.js} +1 -1
- package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-j1jJXYYf.js} +1 -1
- package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
- package/static/app/assets/index-_u5iUHDr.js +10 -0
- package/static/app/assets/input-B0lPdRQZ.js +1 -0
- package/static/app/assets/link-2-CoFbooHS.js +1 -0
- package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-BsyLxtao.js} +1 -1
- package/static/app/assets/primitives-DEbN-d6p.js +1 -0
- package/static/app/assets/search-BybIWPNd.js +1 -0
- package/static/app/assets/{share-2-BH1M-WNi.js → share-2-CVtZ_ewX.js} +1 -1
- package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-CBi2GNWM.js} +1 -1
- package/static/app/assets/{textarea-CCWbUfFB.js → textarea-DNMpB5ih.js} +1 -1
- package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-C83t3GXF.js} +1 -1
- package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
- package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-Dcp1OChy.js} +1 -1
- package/static/app/assets/utils-BlZr7Pd4.js +4 -0
- package/static/app/assets/workspace-jJY4RuAV.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/lattice_brain/graph/_kg_common.py +0 -1331
- package/lattice_brain/graph/discovery_index.py +0 -1141
- package/lattice_brain/graph/retrieval.py +0 -1120
- package/lattice_brain/graph/retrieval_vector.py +0 -1293
- package/lattice_brain/ingestion.py +0 -1525
- package/lattice_brain/multimodal.py +0 -1258
- package/latticeai/core/agent.py +0 -1465
- package/latticeai/core/embedding_providers.py +0 -1196
- package/latticeai/core/file_generation.py +0 -1047
- package/latticeai/integrations/telegram_bot.py +0 -1390
- package/latticeai/models/router.py +0 -1007
- package/latticeai/runtime/build_phases.py +0 -1450
- package/latticeai/services/brain_intelligence.py +0 -1083
- package/latticeai/services/memory_service.py +0 -1177
- package/latticeai/services/model_runtime.py +0 -1281
- package/latticeai/setup/wizard.py +0 -1310
- package/static/app/assets/Act-AWf0SAKp.js +0 -1
- package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
- package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
- package/static/app/assets/Capture-CqOSzyPr.js +0 -1
- package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
- package/static/app/assets/Library-CX-bbhmK.js +0 -1
- package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
- package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
- package/static/app/assets/System-Bu2t5hn1.js +0 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
- package/static/app/assets/brain-DJMoqrwx.js +0 -1
- package/static/app/assets/index-BpYkzcVm.js +0 -10
- package/static/app/assets/input-DSlJJxRs.js +0 -1
- package/static/app/assets/primitives-BCx6TvfG.js +0 -1
- package/static/app/assets/search-Cgy8cCFJ.js +0 -1
- package/static/app/assets/utils-zqPZJxdx.js +0 -4
- package/static/app/assets/workspace-DXTihhfU.js +0 -1
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Images on the same contract as text — with two deliberate differences.
|
|
2
|
+
|
|
3
|
+
**No fallback.** The hash embedder turns text into a real, if crude, cosine
|
|
4
|
+
signal; hashing a file path says nothing about the picture, so an unavailable
|
|
5
|
+
vision model is reported unavailable and the caller skips the embedding rather
|
|
6
|
+
than storing a decoy.
|
|
7
|
+
|
|
8
|
+
**A separate space by default.** A CLIP-family image vector is not comparable
|
|
9
|
+
with a BGE text vector, so ``space == "image"`` means "index these apart and
|
|
10
|
+
join them by late fusion". Only a genuinely shared-space model may declare
|
|
11
|
+
``space == "shared"``, and only then can a text query be scored against image
|
|
12
|
+
vectors.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence
|
|
20
|
+
|
|
21
|
+
from .base import (
|
|
22
|
+
EmbeddingProvider,
|
|
23
|
+
EmbeddingUnavailable,
|
|
24
|
+
_l2_normalize,
|
|
25
|
+
_load_dotted,
|
|
26
|
+
_RemoteConfig,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
#: CLIP ViT-B/32 width — the most common local image-embedding output.
|
|
30
|
+
DEFAULT_VISION_DIM = 512
|
|
31
|
+
#: Image vectors live in their own index and join text results by late fusion.
|
|
32
|
+
VISION_SPACE_IMAGE = "image"
|
|
33
|
+
#: A genuinely multimodal model (CLIP-style) can share the text space — opt-in.
|
|
34
|
+
VISION_SPACE_SHARED = "shared"
|
|
35
|
+
VISION_SPACES = (VISION_SPACE_IMAGE, VISION_SPACE_SHARED)
|
|
36
|
+
VISION_PROVIDER_TYPES = ("mlx", "custom")
|
|
37
|
+
VISION_TARGET_ENV = "LATTICEAI_VISION_EMBEDDING_TARGET"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_KNOWN_VISION_DIMS = {
|
|
41
|
+
"clip-vit-base-patch32": 512,
|
|
42
|
+
"clip-vit-base-patch16": 512,
|
|
43
|
+
"clip-vit-large-patch14": 768,
|
|
44
|
+
"siglip-base-patch16-224": 768,
|
|
45
|
+
"siglip-large-patch16-384": 1024,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _guess_vision_dim(model: str, default: int) -> int:
|
|
50
|
+
key = str(model or "").split("/")[-1].strip().lower()
|
|
51
|
+
key = key.split(":")[0]
|
|
52
|
+
return _KNOWN_VISION_DIMS.get(key, default)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _normalize_space(value: Any) -> str:
|
|
56
|
+
space = str(value or "").strip().lower()
|
|
57
|
+
return space if space in VISION_SPACES else VISION_SPACE_IMAGE
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class VisionEmbeddingProvider(EmbeddingProvider):
|
|
61
|
+
"""Turns an image *file path* into a vector.
|
|
62
|
+
|
|
63
|
+
Subclasses implement :meth:`embed_images`; everything else — the single
|
|
64
|
+
``embed_image``, L2 normalization, locking the index identity to the width
|
|
65
|
+
the model actually returned, and the refusal to embed text outside a shared
|
|
66
|
+
space — is shared here.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
provider = "vision"
|
|
70
|
+
grade = "production"
|
|
71
|
+
#: ``"image"`` (own index, late fusion) or ``"shared"`` (same space as text)
|
|
72
|
+
space: str = VISION_SPACE_IMAGE
|
|
73
|
+
|
|
74
|
+
def __init__(self, cfg: _RemoteConfig):
|
|
75
|
+
self._cfg = cfg
|
|
76
|
+
self.dim = int(cfg.dim or DEFAULT_VISION_DIM)
|
|
77
|
+
self.space = _normalize_space(cfg.extra.get("space"))
|
|
78
|
+
|
|
79
|
+
# ── required ──────────────────────────────────────────────────────────
|
|
80
|
+
def embed_images(self, paths: Sequence[str]) -> List[List[float]]:
|
|
81
|
+
raise NotImplementedError
|
|
82
|
+
|
|
83
|
+
# ── derived (shared) ──────────────────────────────────────────────────
|
|
84
|
+
@property
|
|
85
|
+
def shares_text_space(self) -> bool:
|
|
86
|
+
"""True when a text query may be scored against these vectors."""
|
|
87
|
+
return self.space == VISION_SPACE_SHARED
|
|
88
|
+
|
|
89
|
+
def embed_image(self, path: str) -> List[float]:
|
|
90
|
+
vectors = self.embed_images([path])
|
|
91
|
+
if not vectors:
|
|
92
|
+
raise EmbeddingUnavailable(f"{self.model_id} returned no vector for {path}")
|
|
93
|
+
return vectors[0]
|
|
94
|
+
|
|
95
|
+
def embed_batch(self, texts: Sequence[str]) -> List[List[float]]:
|
|
96
|
+
"""Text side of a multimodal model — only in a shared space.
|
|
97
|
+
|
|
98
|
+
Scoring a BGE query vector against CLIP image vectors produces a number
|
|
99
|
+
with no meaning, so the image-space default refuses rather than
|
|
100
|
+
returning something a caller would rank on.
|
|
101
|
+
"""
|
|
102
|
+
if not self.shares_text_space:
|
|
103
|
+
raise EmbeddingUnavailable(
|
|
104
|
+
f"{self.model_id} embeds images into a separate space; text "
|
|
105
|
+
"queries reach image nodes through late fusion, not this index"
|
|
106
|
+
)
|
|
107
|
+
return self._normalize_rows(self._embed_texts_raw(texts))
|
|
108
|
+
|
|
109
|
+
def _embed_texts_raw(self, texts: Sequence[str]) -> List[List[float]]:
|
|
110
|
+
raise NotImplementedError
|
|
111
|
+
|
|
112
|
+
def _normalize_rows(self, rows: Iterable[Any]) -> List[List[float]]:
|
|
113
|
+
"""L2-normalize, and lock the index identity to the real width."""
|
|
114
|
+
out: List[List[float]] = []
|
|
115
|
+
for row in rows:
|
|
116
|
+
vec = [float(x) for x in (row or [])]
|
|
117
|
+
if not vec:
|
|
118
|
+
raise EmbeddingUnavailable(f"{self.model_id} produced an empty vector")
|
|
119
|
+
self.dim = len(vec)
|
|
120
|
+
self.model_id = self._model_id_with_dim(self.dim)
|
|
121
|
+
out.append(_l2_normalize(vec))
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
def metadata(self) -> Dict[str, Any]:
|
|
125
|
+
data = super().metadata()
|
|
126
|
+
data.update(
|
|
127
|
+
{
|
|
128
|
+
"modality": "image",
|
|
129
|
+
"space": self.space,
|
|
130
|
+
"shares_text_space": self.shares_text_space,
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
return data
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class MLXVisionEmbeddingProvider(VisionEmbeddingProvider):
|
|
137
|
+
"""Local CLIP-family image embedder loaded through ``mlx_clip``.
|
|
138
|
+
|
|
139
|
+
Guarded import, opt-in, never a core dependency: the module must expose
|
|
140
|
+
``load(model)`` returning an encoder with ``encode_image(paths)`` and —
|
|
141
|
+
for a shared space — ``encode_text(texts)``.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
provider = "mlx-vision"
|
|
145
|
+
|
|
146
|
+
def __init__(self, cfg: _RemoteConfig):
|
|
147
|
+
super().__init__(cfg)
|
|
148
|
+
if not cfg.dim:
|
|
149
|
+
self.dim = _guess_vision_dim(cfg.model, DEFAULT_VISION_DIM)
|
|
150
|
+
self.model_id = f"mlx-vision:{cfg.model}:{self.dim}"
|
|
151
|
+
self._encoder: Optional[Any] = None
|
|
152
|
+
|
|
153
|
+
def _load(self) -> Any:
|
|
154
|
+
if self._encoder is not None:
|
|
155
|
+
return self._encoder
|
|
156
|
+
try: # optional dependency; only imported when this provider is used
|
|
157
|
+
import mlx_clip # type: ignore
|
|
158
|
+
|
|
159
|
+
self._encoder = mlx_clip.load(self._cfg.model)
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
raise EmbeddingUnavailable(f"MLX vision model unavailable: {exc}") from exc
|
|
162
|
+
return self._encoder
|
|
163
|
+
|
|
164
|
+
def embed_images(self, paths: Sequence[str]) -> List[List[float]]:
|
|
165
|
+
encoder = self._load()
|
|
166
|
+
try:
|
|
167
|
+
rows = encoder.encode_image(list(paths))
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
raise EmbeddingUnavailable(f"MLX vision embedding failed: {exc}") from exc
|
|
170
|
+
return self._normalize_rows(rows)
|
|
171
|
+
|
|
172
|
+
def _embed_texts_raw(self, texts: Sequence[str]) -> List[List[float]]:
|
|
173
|
+
encoder = self._load()
|
|
174
|
+
encode_text = getattr(encoder, "encode_text", None)
|
|
175
|
+
if not callable(encode_text):
|
|
176
|
+
raise EmbeddingUnavailable(
|
|
177
|
+
f"{self.model_id} has no text encoder, so it cannot back a shared space"
|
|
178
|
+
)
|
|
179
|
+
try:
|
|
180
|
+
return list(encode_text(list(texts)))
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
raise EmbeddingUnavailable(f"MLX vision text embedding failed: {exc}") from exc
|
|
183
|
+
|
|
184
|
+
def health(self) -> Dict[str, Any]:
|
|
185
|
+
try:
|
|
186
|
+
self._load()
|
|
187
|
+
return {"status": "ok", "detail": f"MLX vision model {self._cfg.model} loaded"}
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
return {"status": "unavailable", "detail": str(exc)}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class CustomVisionEmbeddingProvider(VisionEmbeddingProvider):
|
|
193
|
+
"""A user-supplied ``module:callable`` that embeds image paths.
|
|
194
|
+
|
|
195
|
+
The callable receives ``List[str]`` (paths) and returns
|
|
196
|
+
``List[List[float]]``. Configured via ``LATTICEAI_VISION_EMBEDDING_TARGET``.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
provider = "custom-vision"
|
|
200
|
+
|
|
201
|
+
def __init__(self, cfg: _RemoteConfig):
|
|
202
|
+
super().__init__(cfg)
|
|
203
|
+
self._target_ref = str(cfg.extra.get("target") or os.getenv(VISION_TARGET_ENV, ""))
|
|
204
|
+
self.model_id = f"custom-vision:{cfg.model or self._target_ref or 'callable'}:{self.dim}"
|
|
205
|
+
self._fn: Optional[Callable[..., Any]] = None
|
|
206
|
+
|
|
207
|
+
def _load(self) -> Callable[..., Any]:
|
|
208
|
+
if self._fn is not None:
|
|
209
|
+
return self._fn
|
|
210
|
+
self._fn = _load_dotted(self._target_ref, VISION_TARGET_ENV, "vision embedding")
|
|
211
|
+
return self._fn
|
|
212
|
+
|
|
213
|
+
def embed_images(self, paths: Sequence[str]) -> List[List[float]]:
|
|
214
|
+
fn = self._load()
|
|
215
|
+
try:
|
|
216
|
+
rows = list(fn(list(paths)))
|
|
217
|
+
except Exception as exc:
|
|
218
|
+
raise EmbeddingUnavailable(f"custom vision embedding failed: {exc}") from exc
|
|
219
|
+
return self._normalize_rows(rows)
|
|
220
|
+
|
|
221
|
+
def _embed_texts_raw(self, texts: Sequence[str]) -> List[List[float]]:
|
|
222
|
+
# A dotted image embedder is one callable over paths; a shared space
|
|
223
|
+
# would need a second, text-side entry point this contract has no slot
|
|
224
|
+
# for. Saying so beats scoring a query against the wrong function.
|
|
225
|
+
raise EmbeddingUnavailable(
|
|
226
|
+
f"{self.model_id} is an image-only callable and has no text encoder"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def health(self) -> Dict[str, Any]:
|
|
230
|
+
try:
|
|
231
|
+
self._load()
|
|
232
|
+
return {"status": "ok", "detail": f"custom vision target {self._target_ref} loaded"}
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
return {"status": "unavailable", "detail": str(exc)}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def build_vision_provider(
|
|
238
|
+
provider: str,
|
|
239
|
+
*,
|
|
240
|
+
model: str = "",
|
|
241
|
+
dim: int = 0,
|
|
242
|
+
space: str = VISION_SPACE_IMAGE,
|
|
243
|
+
timeout: float = 30.0,
|
|
244
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
245
|
+
) -> VisionEmbeddingProvider:
|
|
246
|
+
"""Construct a vision provider by name. Never makes a network call."""
|
|
247
|
+
kind = str(provider or "").strip().lower()
|
|
248
|
+
cfg = _RemoteConfig(
|
|
249
|
+
model=model,
|
|
250
|
+
dim=int(dim or 0),
|
|
251
|
+
timeout=float(timeout or 30.0),
|
|
252
|
+
extra={"space": space, **(extra or {})},
|
|
253
|
+
)
|
|
254
|
+
if kind == "mlx":
|
|
255
|
+
return MLXVisionEmbeddingProvider(cfg)
|
|
256
|
+
if kind == "custom":
|
|
257
|
+
return CustomVisionEmbeddingProvider(cfg)
|
|
258
|
+
raise ValueError(
|
|
259
|
+
f"unknown vision embedding provider: {provider!r} (expected one of {VISION_PROVIDER_TYPES})"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass
|
|
264
|
+
class ResolvedVisionEmbedder:
|
|
265
|
+
"""A vision provider, or an honest account of why there isn't one."""
|
|
266
|
+
|
|
267
|
+
provider: Optional[VisionEmbeddingProvider]
|
|
268
|
+
requested: str
|
|
269
|
+
health: Dict[str, Any]
|
|
270
|
+
detail: str = ""
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def available(self) -> bool:
|
|
274
|
+
return self.provider is not None
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def space(self) -> str:
|
|
278
|
+
return self.provider.space if self.provider is not None else VISION_SPACE_IMAGE
|
|
279
|
+
|
|
280
|
+
def as_port(self) -> Optional[Callable[[str], List[float]]]:
|
|
281
|
+
"""The one-argument seam Brain Core injects (``None`` when absent).
|
|
282
|
+
|
|
283
|
+
``lattice_brain`` must not import ``latticeai``, so the ingestion
|
|
284
|
+
pipeline never sees this class — only the callable it hands over.
|
|
285
|
+
"""
|
|
286
|
+
if self.provider is None:
|
|
287
|
+
return None
|
|
288
|
+
return self.provider.embed_image
|
|
289
|
+
|
|
290
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
291
|
+
payload: Dict[str, Any] = {
|
|
292
|
+
"requested_provider": self.requested,
|
|
293
|
+
"available": self.available,
|
|
294
|
+
"health": self.health,
|
|
295
|
+
"detail": self.detail,
|
|
296
|
+
}
|
|
297
|
+
if self.provider is not None:
|
|
298
|
+
payload.update(self.provider.metadata())
|
|
299
|
+
return payload
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def resolve_vision_embedder(
|
|
303
|
+
provider: str = "",
|
|
304
|
+
*,
|
|
305
|
+
model: str = "",
|
|
306
|
+
dim: int = 0,
|
|
307
|
+
space: str = VISION_SPACE_IMAGE,
|
|
308
|
+
timeout: float = 30.0,
|
|
309
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
310
|
+
probe: bool = True,
|
|
311
|
+
) -> ResolvedVisionEmbedder:
|
|
312
|
+
"""Build the requested vision provider, or report it unavailable.
|
|
313
|
+
|
|
314
|
+
Unlike :func:`resolve_embedder` there is no fallback: a hashed file path is
|
|
315
|
+
not a picture. An empty ``provider`` means the user never asked for image
|
|
316
|
+
embeddings, which is a configuration state rather than a failure.
|
|
317
|
+
"""
|
|
318
|
+
requested = str(provider or "").strip().lower()
|
|
319
|
+
if not requested:
|
|
320
|
+
return ResolvedVisionEmbedder(
|
|
321
|
+
None,
|
|
322
|
+
"",
|
|
323
|
+
{"status": "unavailable", "detail": "no vision provider configured"},
|
|
324
|
+
"image embeddings are off; set a vision provider to enable them",
|
|
325
|
+
)
|
|
326
|
+
try:
|
|
327
|
+
prov = build_vision_provider(
|
|
328
|
+
requested, model=model, dim=dim, space=space, timeout=timeout, extra=extra
|
|
329
|
+
)
|
|
330
|
+
except Exception as exc:
|
|
331
|
+
return ResolvedVisionEmbedder(
|
|
332
|
+
None,
|
|
333
|
+
requested,
|
|
334
|
+
{"status": "unavailable", "detail": str(exc)},
|
|
335
|
+
f"could not construct vision provider {requested}",
|
|
336
|
+
)
|
|
337
|
+
if not probe:
|
|
338
|
+
return ResolvedVisionEmbedder(
|
|
339
|
+
prov, requested, {"status": "unknown", "detail": "not probed"}, ""
|
|
340
|
+
)
|
|
341
|
+
try:
|
|
342
|
+
health = prov.health()
|
|
343
|
+
except Exception as exc: # a provider probe must never crash startup
|
|
344
|
+
health = {"status": "unavailable", "detail": str(exc)}
|
|
345
|
+
if health.get("status") != "ok":
|
|
346
|
+
return ResolvedVisionEmbedder(
|
|
347
|
+
None,
|
|
348
|
+
requested,
|
|
349
|
+
health,
|
|
350
|
+
f"{requested} vision model unavailable ({health.get('detail', '')})",
|
|
351
|
+
)
|
|
352
|
+
return ResolvedVisionEmbedder(prov, requested, health, "")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Model-agnostic file content generation pipeline.
|
|
2
|
+
|
|
3
|
+
Small local models (gemma/qwen/llama 7B class) asked to "generate an HTML
|
|
4
|
+
file" commonly wrap the payload in chat noise: leading commentary ("Sure!
|
|
5
|
+
Here is your page:"), Markdown fences, ``<think>`` reasoning blocks,
|
|
6
|
+
trailing explanations, or an incomplete document. The previous direct-write
|
|
7
|
+
path saved that reply nearly verbatim, so weak models produced broken files.
|
|
8
|
+
|
|
9
|
+
This package makes the file-creation flow robust regardless of which LLM is
|
|
10
|
+
loaded, by treating the model as an untrusted content source:
|
|
11
|
+
|
|
12
|
+
1. Prompt — extension-aware instructions anchored with the exact first
|
|
13
|
+
line the reply must start with (small models follow examples, not rules).
|
|
14
|
+
→ :mod:`.prompting`
|
|
15
|
+
2. Extract — strip reasoning blocks and conversational framing, pick the
|
|
16
|
+
best fenced block, slice known document boundaries. → :mod:`.extraction`
|
|
17
|
+
3. Validate — per-extension structural checks (HTML document shape, JSON
|
|
18
|
+
parses, CSS has rule blocks, refusal/chat detection). → :mod:`.validation`
|
|
19
|
+
4. Retry — one corrective attempt that tells the model what was wrong.
|
|
20
|
+
→ :mod:`.orchestration`
|
|
21
|
+
5. Repair — deterministic scaffolds guarantee the user still gets a valid
|
|
22
|
+
file even when the model never produces usable output. → :mod:`.repair`
|
|
23
|
+
|
|
24
|
+
Alongside the five steps: :mod:`.sanitize` is the single write-side door every
|
|
25
|
+
entry point runs untrusted content through, :mod:`.inference` guesses a
|
|
26
|
+
filename or a whole project manifest when the user named neither, and
|
|
27
|
+
:mod:`.bundles` checks that a multi-file project's links hold together.
|
|
28
|
+
|
|
29
|
+
The pipeline is pure (no I/O, no FastAPI); the chat layer injects an async
|
|
30
|
+
``generate(context) -> str`` callable.
|
|
31
|
+
|
|
32
|
+
Split into these submodules in v11.3.0 with no behaviour change. Every name
|
|
33
|
+
the single module exposed still resolves from ``latticeai.core.file_generation``.
|
|
34
|
+
|
|
35
|
+
Stubbing note: a name rebound *here* changes only this module's binding — the
|
|
36
|
+
submodule that calls it holds its own. A test standing in for one of the
|
|
37
|
+
regex/helper seams patches the submodule that reads it (for example
|
|
38
|
+
``latticeai.core.file_generation.inference._PKG_NAME_RE``).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
# The single module bound this for its own use, which made it part of the
|
|
44
|
+
# surface callers could reach. Re-exported in the redundant-alias form so it
|
|
45
|
+
# reads as deliberate rather than as a leftover import.
|
|
46
|
+
from latticeai.core.quiet import quiet as quiet
|
|
47
|
+
|
|
48
|
+
from .bundles import _EXTERNAL_REF_PREFIXES as _EXTERNAL_REF_PREFIXES
|
|
49
|
+
from .bundles import _HTML_LOCAL_REF_RE as _HTML_LOCAL_REF_RE
|
|
50
|
+
from .bundles import _local_bundle_refs as _local_bundle_refs
|
|
51
|
+
from .bundles import repair_bundle_references as repair_bundle_references
|
|
52
|
+
from .bundles import validate_project_bundle as validate_project_bundle
|
|
53
|
+
from .extraction import _CHAT_LINE_RE as _CHAT_LINE_RE
|
|
54
|
+
from .extraction import _EXT_FENCE_LANGS as _EXT_FENCE_LANGS
|
|
55
|
+
from .extraction import _FENCE_RE as _FENCE_RE
|
|
56
|
+
from .extraction import _THINK_BLOCK_RE as _THINK_BLOCK_RE
|
|
57
|
+
from .extraction import _THINK_OPEN_RE as _THINK_OPEN_RE
|
|
58
|
+
from .extraction import _ext as _ext
|
|
59
|
+
from .extraction import _slice_html_document as _slice_html_document
|
|
60
|
+
from .extraction import _slice_json_document as _slice_json_document
|
|
61
|
+
from .extraction import _strip_chat_lines as _strip_chat_lines
|
|
62
|
+
from .extraction import extract_file_content as extract_file_content
|
|
63
|
+
from .inference import _CREATE_VERB_RE as _CREATE_VERB_RE
|
|
64
|
+
from .inference import _CSS_HINT_RE as _CSS_HINT_RE
|
|
65
|
+
from .inference import _EXPLICIT_FILENAME_RE as _EXPLICIT_FILENAME_RE
|
|
66
|
+
from .inference import _HTML_HINT_RE as _HTML_HINT_RE
|
|
67
|
+
from .inference import _JS_HINT_RE as _JS_HINT_RE
|
|
68
|
+
from .inference import _PACKAGE_HINT_RE as _PACKAGE_HINT_RE
|
|
69
|
+
from .inference import _PKG_NAME_RE as _PKG_NAME_RE
|
|
70
|
+
from .inference import _PROJECT_NAME_RE as _PROJECT_NAME_RE
|
|
71
|
+
from .inference import _PYTHON_HINT_RE as _PYTHON_HINT_RE
|
|
72
|
+
from .inference import _REACT_HINT_RE as _REACT_HINT_RE
|
|
73
|
+
from .inference import _TYPE_KEYWORDS as _TYPE_KEYWORDS
|
|
74
|
+
from .inference import _VITE_HINT_RE as _VITE_HINT_RE
|
|
75
|
+
from .inference import _python_package_manifest as _python_package_manifest
|
|
76
|
+
from .inference import _react_manifest as _react_manifest
|
|
77
|
+
from .inference import infer_file_target as infer_file_target
|
|
78
|
+
from .inference import infer_project_manifest as infer_project_manifest
|
|
79
|
+
from .orchestration import _salvage_score as _salvage_score
|
|
80
|
+
from .orchestration import generate_file_content as generate_file_content
|
|
81
|
+
from .prompting import _BUNDLE_HTML_MODULE_RULE as _BUNDLE_HTML_MODULE_RULE
|
|
82
|
+
from .prompting import _BUNDLE_HTML_RULE as _BUNDLE_HTML_RULE
|
|
83
|
+
from .prompting import _FIRST_LINE_HINTS as _FIRST_LINE_HINTS
|
|
84
|
+
from .prompting import _TYPE_RULES as _TYPE_RULES
|
|
85
|
+
from .prompting import _bundle_html_rule as _bundle_html_rule
|
|
86
|
+
from .prompting import build_file_generation_context as build_file_generation_context
|
|
87
|
+
from .repair import _repair_html as _repair_html
|
|
88
|
+
from .repair import repair_file_content as repair_file_content
|
|
89
|
+
from .sanitize import PREVIEWABLE_EXTENSIONS as PREVIEWABLE_EXTENSIONS
|
|
90
|
+
from .sanitize import sanitize_write_content as sanitize_write_content
|
|
91
|
+
from .validation import _BRACED_CODE_EXTENSIONS as _BRACED_CODE_EXTENSIONS
|
|
92
|
+
from .validation import _COMMENTARY_RE as _COMMENTARY_RE
|
|
93
|
+
from .validation import _COMPONENT_EXTENSIONS as _COMPONENT_EXTENSIONS
|
|
94
|
+
from .validation import _REFUSAL_RE as _REFUSAL_RE
|
|
95
|
+
from .validation import _check_balanced_delimiters as _check_balanced_delimiters
|
|
96
|
+
from .validation import _check_component_blocks as _check_component_blocks
|
|
97
|
+
from .validation import _looks_like_commentary as _looks_like_commentary
|
|
98
|
+
from .validation import _strip_code_literals as _strip_code_literals
|
|
99
|
+
from .validation import looks_like_refusal as looks_like_refusal
|
|
100
|
+
from .validation import validate_file_content as validate_file_content
|
|
101
|
+
|
|
102
|
+
__all__ = [
|
|
103
|
+
"PREVIEWABLE_EXTENSIONS",
|
|
104
|
+
"build_file_generation_context",
|
|
105
|
+
"extract_file_content",
|
|
106
|
+
"generate_file_content",
|
|
107
|
+
"infer_file_target",
|
|
108
|
+
"infer_project_manifest",
|
|
109
|
+
"looks_like_refusal",
|
|
110
|
+
"repair_bundle_references",
|
|
111
|
+
"repair_file_content",
|
|
112
|
+
"sanitize_write_content",
|
|
113
|
+
"validate_file_content",
|
|
114
|
+
"validate_project_bundle",
|
|
115
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Bundle-level checks: does this set of files hold together?
|
|
2
|
+
|
|
3
|
+
A multi-file project is only as valid as its links. These helpers find the
|
|
4
|
+
local references an HTML document makes, repoint the ones a weak model got
|
|
5
|
+
almost right, and report every file that is invalid or dangling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Dict, List, Tuple
|
|
12
|
+
|
|
13
|
+
from .extraction import _ext
|
|
14
|
+
from .validation import validate_file_content
|
|
15
|
+
|
|
16
|
+
_HTML_LOCAL_REF_RE = re.compile(
|
|
17
|
+
r"(?:href|src)\s*=\s*[\"']([^\"'#?]+)[\"']", re.IGNORECASE
|
|
18
|
+
)
|
|
19
|
+
_EXTERNAL_REF_PREFIXES = ("http://", "https://", "//", "data:", "mailto:", "tel:", "javascript:")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _local_bundle_refs(html: str) -> List[str]:
|
|
23
|
+
"""File references inside an HTML document that must exist in the bundle."""
|
|
24
|
+
refs: List[str] = []
|
|
25
|
+
for ref in _HTML_LOCAL_REF_RE.findall(html or ""):
|
|
26
|
+
candidate = ref.strip()
|
|
27
|
+
if not candidate or candidate.startswith(_EXTERNAL_REF_PREFIXES):
|
|
28
|
+
continue
|
|
29
|
+
if "." not in candidate.rsplit("/", 1)[-1]:
|
|
30
|
+
continue # anchors / routes, not files
|
|
31
|
+
refs.append(candidate)
|
|
32
|
+
return refs
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def repair_bundle_references(files: Dict[str, str]) -> Tuple[Dict[str, str], List[str]]:
|
|
36
|
+
"""Deterministically point dangling HTML refs at real bundle files.
|
|
37
|
+
|
|
38
|
+
A weak model asked for ``style.css`` sometimes links ``styles.css``. When
|
|
39
|
+
a referenced file is missing but the bundle contains exactly one file of
|
|
40
|
+
the same extension, the reference is rewritten. Returns ``(files, fixes)``.
|
|
41
|
+
"""
|
|
42
|
+
names = {p.rsplit("/", 1)[-1] for p in files}
|
|
43
|
+
fixes: List[str] = []
|
|
44
|
+
repaired = dict(files)
|
|
45
|
+
for path, content in files.items():
|
|
46
|
+
if _ext(path) not in (".html", ".htm"):
|
|
47
|
+
continue
|
|
48
|
+
updated = content
|
|
49
|
+
for ref in _local_bundle_refs(content):
|
|
50
|
+
base = ref.rsplit("/", 1)[-1]
|
|
51
|
+
if base in names:
|
|
52
|
+
continue
|
|
53
|
+
same_ext = [n for n in names if _ext(n) == _ext(base)]
|
|
54
|
+
if len(same_ext) == 1:
|
|
55
|
+
updated = updated.replace(ref, same_ext[0])
|
|
56
|
+
fixes.append(f"{path}: '{ref}' -> '{same_ext[0]}'")
|
|
57
|
+
if updated != content:
|
|
58
|
+
repaired[path] = updated
|
|
59
|
+
return repaired, fixes
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate_project_bundle(files: Dict[str, str]) -> Dict[str, Any]:
|
|
63
|
+
"""Bundle-level verification: every file valid, every HTML ref resolvable."""
|
|
64
|
+
issues: List[str] = []
|
|
65
|
+
per_file: Dict[str, Dict[str, Any]] = {}
|
|
66
|
+
names = {p.rsplit("/", 1)[-1] for p in files}
|
|
67
|
+
for path, content in files.items():
|
|
68
|
+
ok, reason = validate_file_content(content, path)
|
|
69
|
+
per_file[path] = {"valid": ok, "reason": reason}
|
|
70
|
+
if not ok:
|
|
71
|
+
issues.append(f"{path}: {reason}")
|
|
72
|
+
if _ext(path) in (".html", ".htm"):
|
|
73
|
+
for ref in _local_bundle_refs(content):
|
|
74
|
+
if ref.rsplit("/", 1)[-1] not in names:
|
|
75
|
+
issues.append(f"{path}: references missing file '{ref}'")
|
|
76
|
+
return {"ok": not issues, "issues": issues, "files": per_file}
|