python-lucide 0.3.0__tar.gz → 0.5.0__tar.gz
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.
- {python_lucide-0.3.0 → python_lucide-0.5.0}/PKG-INFO +57 -1
- {python_lucide-0.3.0 → python_lucide-0.5.0}/README.md +55 -0
- {python_lucide-0.3.0 → python_lucide-0.5.0}/pyproject.toml +3 -2
- {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/build_clusters.py +88 -51
- {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/build_search.py +51 -28
- {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/cli.py +146 -31
- python_lucide-0.5.0/src/lucide/config.py +100 -0
- {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/data/lucide-icon-clusters.json +2 -2
- {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/search.py +92 -29
- python_lucide-0.5.0/tests/build_clusters_test.py +107 -0
- {python_lucide-0.3.0 → python_lucide-0.5.0}/tests/build_search_test.py +48 -10
- {python_lucide-0.3.0 → python_lucide-0.5.0}/tests/cli_test.py +132 -15
- python_lucide-0.5.0/tests/conftest.py +24 -0
- {python_lucide-0.3.0 → python_lucide-0.5.0}/tests/search_test.py +93 -14
- python_lucide-0.3.0/src/lucide/config.py +0 -30
- {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/__init__.py +0 -0
- {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/core.py +0 -0
- {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/data/__init__.py +0 -0
- {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/data/gemini-icon-descriptions.jsonl +0 -0
- {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/data/lucide-icons.db +0 -0
- {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/db.py +0 -0
- {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/dev_utils.py +0 -0
- {python_lucide-0.3.0 → python_lucide-0.5.0}/tests/__init__.py +0 -0
- {python_lucide-0.3.0 → python_lucide-0.5.0}/tests/core_test.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: python-lucide
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A Python package for working with Lucide icons
|
|
5
5
|
Keywords: lucide,icons,svg,sqlite
|
|
6
6
|
Author: Mike Macpherson
|
|
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Dist: cairosvg>=2.7.0 ; extra == 'search'
|
|
18
19
|
Requires-Dist: fastembed>=0.4.0 ; extra == 'search'
|
|
19
20
|
Requires-Python: >=3.10
|
|
20
21
|
Project-URL: Bug Tracker, https://github.com/mmacpherson/python-lucide/issues
|
|
@@ -43,6 +44,7 @@ in your Python projects, with no javascript in sight.
|
|
|
43
44
|
|
|
44
45
|
## Features
|
|
45
46
|
- 🎨 **Access 1600+ Lucide icons** directly from Python
|
|
47
|
+
- 🔍 **Semantic search** - find icons by meaning, in the terminal or [in the browser](https://mmacpherson.github.io/python-lucide/)
|
|
46
48
|
- 🛠 **Customize icons** with classes, sizes, colors, and other SVG attributes
|
|
47
49
|
- 🚀 **Framework-friendly** with examples for FastHTML, Flask, Django, and more
|
|
48
50
|
- 📦 **Lightweight** with minimal dependencies
|
|
@@ -83,6 +85,60 @@ lucide_icon("user", fill="currentColor") # Inherit color from CSS
|
|
|
83
85
|
lucide_icon("user", stroke="#ff6b6b") # Hex colors work too
|
|
84
86
|
```
|
|
85
87
|
|
|
88
|
+
## Semantic Search
|
|
89
|
+
|
|
90
|
+
Find icons by describing what you mean, not what they're called — in English
|
|
91
|
+
or [50+ languages](https://mmacpherson.github.io/python-lucide/):
|
|
92
|
+
|
|
93
|
+
<p align="center">
|
|
94
|
+
<a href="https://mmacpherson.github.io/python-lucide/">
|
|
95
|
+
<img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-english.png" width="49%" alt="Searching "celebrate a big achievement" returns trophy, award, medal, party-popper">
|
|
96
|
+
<img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-multilingual.png" width="49%" alt="Searching in Japanese with the multilingual model returns key, user-key, lock icons">
|
|
97
|
+
</a>
|
|
98
|
+
</p>
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# One-off, no install (requires uv)
|
|
102
|
+
uvx --from "python-lucide[search]" lucide search "waiting for a download"
|
|
103
|
+
|
|
104
|
+
# Or install the search extra
|
|
105
|
+
pip install "python-lucide[search]"
|
|
106
|
+
lucide search "celebrate a big achievement"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
<img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-cli.png" width="360" alt="Terminal output of lucide search: each result icon renders inline next to its name and match score">
|
|
110
|
+
|
|
111
|
+
In terminals supporting the Kitty graphics protocol (kitty, Ghostty,
|
|
112
|
+
WezTerm), the icons render inline as above — inside tmux too, with
|
|
113
|
+
`tmux set -g allow-passthrough on`. Elsewhere you get the plain text
|
|
114
|
+
list. (Rendering uses the cairo system library; if it's missing, the
|
|
115
|
+
CLI prints a hint.)
|
|
116
|
+
|
|
117
|
+
Icons are matched against AI-generated descriptions using the same embedding
|
|
118
|
+
model the [web app](https://mmacpherson.github.io/python-lucide/) uses,
|
|
119
|
+
so descriptive phrases ("an idea just occurred to me") work better than
|
|
120
|
+
keywords. Options:
|
|
121
|
+
|
|
122
|
+
- `--model multilingual` — search in 50+ languages (default `bge-small` ranks
|
|
123
|
+
best for English)
|
|
124
|
+
- `--limit N` — number of results; `--verbose` — include each icon's description
|
|
125
|
+
|
|
126
|
+
The first search downloads the embedding model (~67 MB) and the pre-built
|
|
127
|
+
search index (11 MB); both are cached, and every search after that runs
|
|
128
|
+
locally in well under a second.
|
|
129
|
+
|
|
130
|
+
The same search is available as a Python API:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from lucide.search import search_icons
|
|
134
|
+
|
|
135
|
+
for r in search_icons("secure login", limit=5):
|
|
136
|
+
print(r.name, r.score)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Prefer not to install anything? The [browser version](https://mmacpherson.github.io/python-lucide/)
|
|
140
|
+
runs the whole pipeline client-side.
|
|
141
|
+
|
|
86
142
|
## Framework Integration Examples
|
|
87
143
|
|
|
88
144
|
### Starlette
|
|
@@ -18,6 +18,7 @@ in your Python projects, with no javascript in sight.
|
|
|
18
18
|
|
|
19
19
|
## Features
|
|
20
20
|
- 🎨 **Access 1600+ Lucide icons** directly from Python
|
|
21
|
+
- 🔍 **Semantic search** - find icons by meaning, in the terminal or [in the browser](https://mmacpherson.github.io/python-lucide/)
|
|
21
22
|
- 🛠 **Customize icons** with classes, sizes, colors, and other SVG attributes
|
|
22
23
|
- 🚀 **Framework-friendly** with examples for FastHTML, Flask, Django, and more
|
|
23
24
|
- 📦 **Lightweight** with minimal dependencies
|
|
@@ -58,6 +59,60 @@ lucide_icon("user", fill="currentColor") # Inherit color from CSS
|
|
|
58
59
|
lucide_icon("user", stroke="#ff6b6b") # Hex colors work too
|
|
59
60
|
```
|
|
60
61
|
|
|
62
|
+
## Semantic Search
|
|
63
|
+
|
|
64
|
+
Find icons by describing what you mean, not what they're called — in English
|
|
65
|
+
or [50+ languages](https://mmacpherson.github.io/python-lucide/):
|
|
66
|
+
|
|
67
|
+
<p align="center">
|
|
68
|
+
<a href="https://mmacpherson.github.io/python-lucide/">
|
|
69
|
+
<img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-english.png" width="49%" alt="Searching "celebrate a big achievement" returns trophy, award, medal, party-popper">
|
|
70
|
+
<img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-multilingual.png" width="49%" alt="Searching in Japanese with the multilingual model returns key, user-key, lock icons">
|
|
71
|
+
</a>
|
|
72
|
+
</p>
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# One-off, no install (requires uv)
|
|
76
|
+
uvx --from "python-lucide[search]" lucide search "waiting for a download"
|
|
77
|
+
|
|
78
|
+
# Or install the search extra
|
|
79
|
+
pip install "python-lucide[search]"
|
|
80
|
+
lucide search "celebrate a big achievement"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
<img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-cli.png" width="360" alt="Terminal output of lucide search: each result icon renders inline next to its name and match score">
|
|
84
|
+
|
|
85
|
+
In terminals supporting the Kitty graphics protocol (kitty, Ghostty,
|
|
86
|
+
WezTerm), the icons render inline as above — inside tmux too, with
|
|
87
|
+
`tmux set -g allow-passthrough on`. Elsewhere you get the plain text
|
|
88
|
+
list. (Rendering uses the cairo system library; if it's missing, the
|
|
89
|
+
CLI prints a hint.)
|
|
90
|
+
|
|
91
|
+
Icons are matched against AI-generated descriptions using the same embedding
|
|
92
|
+
model the [web app](https://mmacpherson.github.io/python-lucide/) uses,
|
|
93
|
+
so descriptive phrases ("an idea just occurred to me") work better than
|
|
94
|
+
keywords. Options:
|
|
95
|
+
|
|
96
|
+
- `--model multilingual` — search in 50+ languages (default `bge-small` ranks
|
|
97
|
+
best for English)
|
|
98
|
+
- `--limit N` — number of results; `--verbose` — include each icon's description
|
|
99
|
+
|
|
100
|
+
The first search downloads the embedding model (~67 MB) and the pre-built
|
|
101
|
+
search index (11 MB); both are cached, and every search after that runs
|
|
102
|
+
locally in well under a second.
|
|
103
|
+
|
|
104
|
+
The same search is available as a Python API:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from lucide.search import search_icons
|
|
108
|
+
|
|
109
|
+
for r in search_icons("secure login", limit=5):
|
|
110
|
+
print(r.name, r.score)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Prefer not to install anything? The [browser version](https://mmacpherson.github.io/python-lucide/)
|
|
114
|
+
runs the whole pipeline client-side.
|
|
115
|
+
|
|
61
116
|
## Framework Integration Examples
|
|
62
117
|
|
|
63
118
|
### Starlette
|
|
@@ -11,6 +11,7 @@ dev = [
|
|
|
11
11
|
"mypy>=1.0.0",
|
|
12
12
|
"plotly>=5.0.0",
|
|
13
13
|
"pre-commit>=3.0.0",
|
|
14
|
+
"pydantic-ai-slim[google]>=1.0.0",
|
|
14
15
|
"pytest-cov>=4.0.0",
|
|
15
16
|
"pytest>=7.0.0",
|
|
16
17
|
"ruff>=0.1.0",
|
|
@@ -40,10 +41,10 @@ license = {text = "MIT"}
|
|
|
40
41
|
name = "python-lucide"
|
|
41
42
|
readme = "README.md"
|
|
42
43
|
requires-python = ">=3.10"
|
|
43
|
-
version = "0.
|
|
44
|
+
version = "0.5.0"
|
|
44
45
|
|
|
45
46
|
[project.optional-dependencies]
|
|
46
|
-
search = ["fastembed>=0.4.0"]
|
|
47
|
+
search = ["cairosvg>=2.7.0", "fastembed>=0.4.0"]
|
|
47
48
|
|
|
48
49
|
[project.scripts]
|
|
49
50
|
check-lucide-version = "lucide.dev_utils:print_version_status"
|
|
@@ -13,51 +13,68 @@ import logging
|
|
|
13
13
|
import os
|
|
14
14
|
import pathlib
|
|
15
15
|
import sqlite3
|
|
16
|
-
import urllib.request
|
|
17
16
|
from datetime import datetime, timezone
|
|
18
17
|
|
|
18
|
+
from pydantic import BaseModel, field_validator
|
|
19
|
+
from pydantic_ai import Agent
|
|
20
|
+
from pydantic_ai.models import Model
|
|
21
|
+
|
|
22
|
+
from .config import DEFAULT_SEARCH_MODEL_ID
|
|
23
|
+
|
|
19
24
|
logger = logging.getLogger(__name__)
|
|
20
25
|
|
|
21
26
|
CLUSTER_NAMING_MODEL = "gemini-2.5-flash"
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
NAMING_INSTRUCTIONS = """\
|
|
29
|
+
You name clusters of icons. Given icon names that form a visual/semantic
|
|
30
|
+
cluster, respond with a short, evocative theme name (2-4 words).
|
|
31
|
+
Be specific about what unifies the icons.
|
|
32
|
+
Do not use generic labels like "UI elements" or "miscellaneous"."""
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
# Generous bound for a "2-4 word" name; anything longer means the model
|
|
35
|
+
# ignored the instruction (e.g. leaked its reasoning)
|
|
36
|
+
MAX_THEME_LENGTH = 40
|
|
29
37
|
|
|
30
|
-
Return ONLY the theme name, nothing else."""
|
|
31
38
|
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
"{model}:generateContent?key={api_key}"
|
|
35
|
-
)
|
|
39
|
+
def _sanitize_theme(raw: str) -> str | None:
|
|
40
|
+
"""Validate a model-proposed theme name.
|
|
36
41
|
|
|
42
|
+
Models occasionally return their full chain-of-thought instead of just
|
|
43
|
+
the name — two shipped clusters once carried ~4k-char reasoning dumps
|
|
44
|
+
as their theme. Reject anything multi-line or implausibly long rather
|
|
45
|
+
than trying to salvage it.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
raw: The raw model response text.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The cleaned theme name, or None if the response is unusable.
|
|
52
|
+
"""
|
|
53
|
+
theme = raw.strip().strip("\"'").strip()
|
|
54
|
+
if not theme or "\n" in theme or len(theme) > MAX_THEME_LENGTH:
|
|
55
|
+
return None
|
|
56
|
+
return theme
|
|
37
57
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
except Exception:
|
|
59
|
-
logger.warning("Gemini API call failed", exc_info=True)
|
|
60
|
-
return None
|
|
58
|
+
|
|
59
|
+
class ClusterTheme(BaseModel):
|
|
60
|
+
"""Structured output for cluster naming.
|
|
61
|
+
|
|
62
|
+
A failed validation here becomes a retry request to the model, so a
|
|
63
|
+
leaked chain-of-thought gets re-asked instead of stored or discarded.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
theme: str
|
|
67
|
+
|
|
68
|
+
@field_validator("theme")
|
|
69
|
+
@classmethod
|
|
70
|
+
def _must_be_short_single_line(cls, value: str) -> str:
|
|
71
|
+
clean = _sanitize_theme(value)
|
|
72
|
+
if clean is None:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"theme must be a single line of at most {MAX_THEME_LENGTH} "
|
|
75
|
+
"characters — return only the 2-4 word name itself"
|
|
76
|
+
)
|
|
77
|
+
return clean
|
|
61
78
|
|
|
62
79
|
|
|
63
80
|
def discover_clusters(
|
|
@@ -83,7 +100,9 @@ def discover_clusters(
|
|
|
83
100
|
"SELECT e.name, e.embedding, d.description "
|
|
84
101
|
"FROM icon_embeddings e "
|
|
85
102
|
"JOIN icon_descriptions d ON e.name = d.name "
|
|
86
|
-
"
|
|
103
|
+
"WHERE e.model = ? "
|
|
104
|
+
"ORDER BY e.name",
|
|
105
|
+
(DEFAULT_SEARCH_MODEL_ID,),
|
|
87
106
|
).fetchall()
|
|
88
107
|
conn.close()
|
|
89
108
|
|
|
@@ -140,19 +159,40 @@ def name_clusters(
|
|
|
140
159
|
data: dict,
|
|
141
160
|
*,
|
|
142
161
|
api_key: str | None = None,
|
|
162
|
+
model: Model | None = None,
|
|
143
163
|
) -> dict:
|
|
144
|
-
"""Name each cluster using Gemini Flash.
|
|
164
|
+
"""Name each cluster using Gemini Flash via a Pydantic AI agent.
|
|
165
|
+
|
|
166
|
+
Output is validated by ``ClusterTheme``; an invalid response (e.g. a
|
|
167
|
+
leaked chain-of-thought) triggers an automatic retry instead of being
|
|
168
|
+
stored. Only after retries are exhausted does a cluster fall back to a
|
|
169
|
+
``Cluster {id}`` placeholder.
|
|
145
170
|
|
|
146
171
|
Args:
|
|
147
172
|
data: Output from ``discover_clusters()``.
|
|
148
173
|
api_key: Gemini API key. Falls back to ``GEMINI_API_KEY`` env var.
|
|
174
|
+
model: Model override, used by tests to avoid real API calls.
|
|
149
175
|
|
|
150
176
|
Returns:
|
|
151
177
|
The same data dict with ``theme`` populated for each cluster.
|
|
152
178
|
"""
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
179
|
+
if model is None:
|
|
180
|
+
from pydantic_ai.models.google import GoogleModel # noqa: PLC0415
|
|
181
|
+
from pydantic_ai.providers.google import GoogleProvider # noqa: PLC0415
|
|
182
|
+
|
|
183
|
+
api_key = api_key or os.environ.get("GEMINI_API_KEY")
|
|
184
|
+
if not api_key:
|
|
185
|
+
raise ValueError("Gemini API key required. Set GEMINI_API_KEY.")
|
|
186
|
+
model = GoogleModel(
|
|
187
|
+
CLUSTER_NAMING_MODEL, provider=GoogleProvider(api_key=api_key)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
agent = Agent(
|
|
191
|
+
model,
|
|
192
|
+
output_type=ClusterTheme,
|
|
193
|
+
instructions=NAMING_INSTRUCTIONS,
|
|
194
|
+
output_retries=3,
|
|
195
|
+
)
|
|
156
196
|
|
|
157
197
|
clusters = data["clusters"]
|
|
158
198
|
for lid in sorted(clusters, key=lambda k: -len(clusters[k]["icons"])):
|
|
@@ -163,17 +203,15 @@ def name_clusters(
|
|
|
163
203
|
icons = clusters[lid]["icons"]
|
|
164
204
|
# Cap at 40 names to keep prompt short
|
|
165
205
|
icon_names = ", ".join(icons[:40])
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
logger.info("Cluster %s (%d icons): %s", lid, len(icons), theme)
|
|
174
|
-
else:
|
|
206
|
+
try:
|
|
207
|
+
result = agent.run_sync(f"Icon cluster: {icon_names}")
|
|
208
|
+
clusters[lid]["theme"] = result.output.theme
|
|
209
|
+
logger.info(
|
|
210
|
+
"Cluster %s (%d icons): %s", lid, len(icons), result.output.theme
|
|
211
|
+
)
|
|
212
|
+
except Exception:
|
|
175
213
|
clusters[lid]["theme"] = f"Cluster {lid}"
|
|
176
|
-
logger.warning("Failed to name cluster %s", lid)
|
|
214
|
+
logger.warning("Failed to name cluster %s", lid, exc_info=True)
|
|
177
215
|
|
|
178
216
|
return data
|
|
179
217
|
|
|
@@ -260,8 +298,7 @@ def build_cluster_visualization(
|
|
|
260
298
|
fig.update_layout(
|
|
261
299
|
title={
|
|
262
300
|
"text": (
|
|
263
|
-
"Lucide Icon Embedding Clusters \u2014 "
|
|
264
|
-
"themes discovered via HDBSCAN in 768d space"
|
|
301
|
+
"Lucide Icon Embedding Clusters \u2014 themes discovered via HDBSCAN"
|
|
265
302
|
),
|
|
266
303
|
"font": {"size": 16},
|
|
267
304
|
},
|
|
@@ -30,10 +30,9 @@ from datetime import datetime, timezone
|
|
|
30
30
|
from typing import TypedDict
|
|
31
31
|
|
|
32
32
|
from .config import (
|
|
33
|
-
DEFAULT_EMBEDDING_DIM,
|
|
34
|
-
DEFAULT_EMBEDDING_MODEL,
|
|
35
33
|
DEFAULT_VLM_MODEL,
|
|
36
|
-
|
|
34
|
+
EMBEDDING_MODELS,
|
|
35
|
+
SEARCH_DB_SCHEMA_VERSION,
|
|
37
36
|
)
|
|
38
37
|
|
|
39
38
|
logger = logging.getLogger(__name__)
|
|
@@ -486,9 +485,10 @@ def _ensure_search_tables(conn: sqlite3.Connection) -> None:
|
|
|
486
485
|
)
|
|
487
486
|
conn.execute(
|
|
488
487
|
"CREATE TABLE IF NOT EXISTS icon_embeddings ("
|
|
489
|
-
" name TEXT
|
|
488
|
+
" name TEXT NOT NULL,"
|
|
490
489
|
" embedding BLOB NOT NULL,"
|
|
491
|
-
" model TEXT NOT NULL"
|
|
490
|
+
" model TEXT NOT NULL,"
|
|
491
|
+
" PRIMARY KEY (name, model)"
|
|
492
492
|
")"
|
|
493
493
|
)
|
|
494
494
|
conn.execute(
|
|
@@ -513,23 +513,35 @@ def _write_search_db( # noqa: PLR0913
|
|
|
513
513
|
search_db_path: pathlib.Path,
|
|
514
514
|
ordered_names: list[str],
|
|
515
515
|
records: dict[str, DescriptionRecord],
|
|
516
|
-
embeddings:
|
|
516
|
+
embeddings: dict[str, dict[str, object]],
|
|
517
517
|
clusters_path: pathlib.Path,
|
|
518
518
|
*,
|
|
519
519
|
version: str | None = None,
|
|
520
520
|
verbose: bool = False,
|
|
521
521
|
) -> None:
|
|
522
|
-
"""Write descriptions, embeddings, clusters, and metadata to SQLite.
|
|
522
|
+
"""Write descriptions, embeddings, clusters, and metadata to SQLite.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
search_db_path: Output SQLite path (recreated from scratch).
|
|
526
|
+
ordered_names: Icon names in insertion order.
|
|
527
|
+
records: Description records keyed by icon name.
|
|
528
|
+
embeddings: Embedding vectors as ``{model_id: {icon_name: vector}}``.
|
|
529
|
+
A model may cover only a subset of icons; absent names are
|
|
530
|
+
simply not inserted for that model.
|
|
531
|
+
clusters_path: JSON file with cluster assignments.
|
|
532
|
+
version: Lucide icon-set version for the metadata table.
|
|
533
|
+
verbose: Verbose logging.
|
|
534
|
+
"""
|
|
523
535
|
import numpy as np # noqa: PLC0415
|
|
524
536
|
|
|
525
537
|
search_db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
538
|
+
# Recreate rather than DELETE-and-reuse: the schema itself may have
|
|
539
|
+
# changed since the last build (CREATE IF NOT EXISTS won't migrate it)
|
|
540
|
+
search_db_path.unlink(missing_ok=True)
|
|
526
541
|
conn = sqlite3.connect(search_db_path)
|
|
527
542
|
try:
|
|
528
543
|
_ensure_search_tables(conn)
|
|
529
544
|
|
|
530
|
-
conn.execute("DELETE FROM icon_descriptions")
|
|
531
|
-
conn.execute("DELETE FROM icon_embeddings")
|
|
532
|
-
|
|
533
545
|
for name in ordered_names:
|
|
534
546
|
rec = records[name]
|
|
535
547
|
conn.execute(
|
|
@@ -538,15 +550,18 @@ def _write_search_db( # noqa: PLR0913
|
|
|
538
550
|
(name, rec["description"], rec["model"]),
|
|
539
551
|
)
|
|
540
552
|
|
|
541
|
-
for
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
(name,
|
|
546
|
-
|
|
553
|
+
for model_id, vectors_by_name in embeddings.items():
|
|
554
|
+
for name in ordered_names:
|
|
555
|
+
if name not in vectors_by_name:
|
|
556
|
+
continue
|
|
557
|
+
blob = np.array(vectors_by_name[name], dtype=np.float32).tobytes()
|
|
558
|
+
conn.execute(
|
|
559
|
+
"INSERT INTO icon_embeddings (name, embedding, model)"
|
|
560
|
+
" VALUES (?, ?, ?)",
|
|
561
|
+
(name, blob, model_id),
|
|
562
|
+
)
|
|
547
563
|
|
|
548
564
|
# Load clusters
|
|
549
|
-
conn.execute("DELETE FROM icon_clusters")
|
|
550
565
|
cluster_data = json.loads(clusters_path.read_text())
|
|
551
566
|
for cid, cluster in cluster_data["clusters"].items():
|
|
552
567
|
theme = cluster.get("theme", f"Cluster {cid}")
|
|
@@ -584,10 +599,11 @@ def _write_search_db( # noqa: PLR0913
|
|
|
584
599
|
first = records[ordered_names[0]]
|
|
585
600
|
resolved_version = version or first.get("lucide_version", "unknown")
|
|
586
601
|
now = datetime.now(tz=timezone.utc).isoformat()
|
|
602
|
+
model_dims = json.dumps({mid: EMBEDDING_MODELS[mid].dim for mid in embeddings})
|
|
587
603
|
for key, value in [
|
|
588
604
|
("version", resolved_version),
|
|
589
|
-
("
|
|
590
|
-
("
|
|
605
|
+
("schema_version", str(SEARCH_DB_SCHEMA_VERSION)),
|
|
606
|
+
("embedding_models", model_dims),
|
|
591
607
|
("description_model", DEFAULT_VLM_MODEL),
|
|
592
608
|
("built_at", now),
|
|
593
609
|
]:
|
|
@@ -631,9 +647,10 @@ def build_search_db(
|
|
|
631
647
|
) -> None:
|
|
632
648
|
"""Build the SQLite search database from descriptions, embeddings, and clusters.
|
|
633
649
|
|
|
634
|
-
Reads descriptions from *jsonl_path*, computes
|
|
635
|
-
|
|
636
|
-
*search_db_path*. The DB
|
|
650
|
+
Reads descriptions from *jsonl_path*, computes one embedding set per
|
|
651
|
+
model in ``EMBEDDING_MODELS`` with fastembed, loads cluster assignments
|
|
652
|
+
from *clusters_path*, and writes everything to *search_db_path*. The DB
|
|
653
|
+
is rebuilt from scratch each time.
|
|
637
654
|
|
|
638
655
|
When *icons_db_path* is provided, only icons present in that database are
|
|
639
656
|
included and the version metadata is read from it.
|
|
@@ -687,12 +704,18 @@ def build_search_db(
|
|
|
687
704
|
if rec["categories"]:
|
|
688
705
|
parts.append(f"Categories: {', '.join(rec['categories'])}")
|
|
689
706
|
parts.append(rec["description"])
|
|
690
|
-
return
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
707
|
+
return ". ".join(parts)
|
|
708
|
+
|
|
709
|
+
base_texts = [_embedding_text(records[n]) for n in ordered_names]
|
|
710
|
+
embeddings: dict[str, dict[str, object]] = {}
|
|
711
|
+
for model_id, model_cfg in EMBEDDING_MODELS.items():
|
|
712
|
+
texts = [f"{model_cfg.document_prefix}{t}" for t in base_texts]
|
|
713
|
+
embedder = TextEmbedding(model_name=model_cfg.fastembed_model)
|
|
714
|
+
vecs = list(embedder.embed(texts))
|
|
715
|
+
embeddings[model_id] = dict(zip(ordered_names, vecs, strict=True))
|
|
716
|
+
logger.info(
|
|
717
|
+
"Computed %d embeddings with %s", len(texts), model_cfg.fastembed_model
|
|
718
|
+
)
|
|
696
719
|
|
|
697
720
|
_write_search_db(
|
|
698
721
|
search_db_path,
|