kiwimatecoder 0.1.0__py3-none-any.whl
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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Incremental, gitignore-aware builder for the codebase index.
|
|
2
|
+
|
|
3
|
+
A build walks the workspace using the same ignore rules as the file tools,
|
|
4
|
+
compares each file's ``mtime``/``size`` against the stored entry, and only
|
|
5
|
+
re-tokenizes files that are new or changed. Deleted files are dropped. Large
|
|
6
|
+
files are chunked (with overlap) so embeddings can be stored per chunk when an
|
|
7
|
+
embedding provider is configured.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from kiwimatecoder import config
|
|
22
|
+
from kiwimatecoder.index import embeddings
|
|
23
|
+
from kiwimatecoder.index.store import load_store, save_store
|
|
24
|
+
from kiwimatecoder.tools.paths import get_workspace_ignore
|
|
25
|
+
|
|
26
|
+
MIN_TOKEN_LENGTH = 2
|
|
27
|
+
CHUNK_LINES = 60
|
|
28
|
+
CHUNK_OVERLAP = 10
|
|
29
|
+
HEAD_BYTES = 1024
|
|
30
|
+
|
|
31
|
+
Embedder = Callable[[list[str]], list[list[float]]]
|
|
32
|
+
|
|
33
|
+
_WORD_RE = re.compile(r"[A-Za-z0-9_]+")
|
|
34
|
+
_CAMEL_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
35
|
+
|
|
36
|
+
_BUILD_LOCK = threading.Lock()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class BuildStats:
|
|
41
|
+
"""What one incremental build changed."""
|
|
42
|
+
|
|
43
|
+
added: int = 0
|
|
44
|
+
updated: int = 0
|
|
45
|
+
removed: int = 0
|
|
46
|
+
unchanged: int = 0
|
|
47
|
+
files: int = 0
|
|
48
|
+
terms: int = 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def tokenize(text: str) -> list[str]:
|
|
52
|
+
"""Split text into lowercase index terms.
|
|
53
|
+
|
|
54
|
+
Runs of alphanumerics and underscores are kept, camelCase and snake_case
|
|
55
|
+
are split into their parts, and tokens shorter than
|
|
56
|
+
:data:`MIN_TOKEN_LENGTH` are dropped.
|
|
57
|
+
"""
|
|
58
|
+
tokens: list[str] = []
|
|
59
|
+
for word in _WORD_RE.findall(text):
|
|
60
|
+
for part in _CAMEL_RE.split(word):
|
|
61
|
+
for piece in part.split("_"):
|
|
62
|
+
piece = piece.lower()
|
|
63
|
+
if len(piece) >= MIN_TOKEN_LENGTH:
|
|
64
|
+
tokens.append(piece)
|
|
65
|
+
return tokens
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def count_tokens(text: str) -> dict[str, int]:
|
|
69
|
+
"""Return term -> frequency for ``text``."""
|
|
70
|
+
return dict(Counter(tokenize(text)))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def chunk_text(
|
|
74
|
+
text: str, *, size: int = CHUNK_LINES, overlap: int = CHUNK_OVERLAP
|
|
75
|
+
) -> list[tuple[int, str]]:
|
|
76
|
+
"""Split text into overlapping line windows for embeddings.
|
|
77
|
+
|
|
78
|
+
Returns 1-based ``(start_line, chunk)`` pairs; a file shorter than
|
|
79
|
+
``size`` lines yields a single chunk and empty text yields none.
|
|
80
|
+
"""
|
|
81
|
+
lines = text.splitlines()
|
|
82
|
+
if not lines:
|
|
83
|
+
return []
|
|
84
|
+
step = max(1, size - overlap)
|
|
85
|
+
chunks: list[tuple[int, str]] = []
|
|
86
|
+
start = 0
|
|
87
|
+
while start < len(lines):
|
|
88
|
+
window = lines[start : start + size]
|
|
89
|
+
if not window:
|
|
90
|
+
break
|
|
91
|
+
chunks.append((start + 1, "\n".join(window)))
|
|
92
|
+
if start + size >= len(lines):
|
|
93
|
+
break
|
|
94
|
+
start += step
|
|
95
|
+
return chunks
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def embeddings_configured(settings: dict[str, Any] | None = None) -> bool:
|
|
99
|
+
"""Whether a provider and model are configured for embeddings."""
|
|
100
|
+
settings = settings or config.get_index()
|
|
101
|
+
embeddings_settings = settings.get("embeddings") or {}
|
|
102
|
+
return bool(
|
|
103
|
+
str(embeddings_settings.get("provider") or "").strip()
|
|
104
|
+
and str(embeddings_settings.get("model") or "").strip()
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _collect_files(root: Path, max_files: int) -> tuple[list[Path], bool]:
|
|
109
|
+
"""Collect indexable files, honoring ignore rules and the file cap.
|
|
110
|
+
|
|
111
|
+
Returns ``(files, capped)``; ``capped`` is True when the walk stopped at
|
|
112
|
+
``max_files`` with potentially more files still unseen.
|
|
113
|
+
"""
|
|
114
|
+
ignore = get_workspace_ignore(root)
|
|
115
|
+
collected: list[Path] = []
|
|
116
|
+
for dirpath, dirnames, filenames in os.walk(
|
|
117
|
+
root, topdown=True, followlinks=False
|
|
118
|
+
):
|
|
119
|
+
dir_p = Path(dirpath)
|
|
120
|
+
dirnames[:] = [
|
|
121
|
+
name for name in dirnames if not ignore.is_ignored(dir_p / name, True)
|
|
122
|
+
]
|
|
123
|
+
dirnames.sort()
|
|
124
|
+
for name in sorted(filenames):
|
|
125
|
+
file_p = dir_p / name
|
|
126
|
+
if ignore.is_ignored(file_p, is_dir=False):
|
|
127
|
+
continue
|
|
128
|
+
collected.append(file_p)
|
|
129
|
+
if len(collected) >= max_files:
|
|
130
|
+
return collected, True
|
|
131
|
+
return collected, False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_binary(path: Path) -> bool:
|
|
135
|
+
try:
|
|
136
|
+
with path.open("rb") as handle:
|
|
137
|
+
head = handle.read(HEAD_BYTES)
|
|
138
|
+
except OSError:
|
|
139
|
+
return True
|
|
140
|
+
return b"\x00" in head
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _remove_entry(
|
|
144
|
+
files: dict[str, dict[str, Any]], df: dict[str, int], rel: str
|
|
145
|
+
) -> None:
|
|
146
|
+
entry = files.pop(rel, None)
|
|
147
|
+
if entry is None:
|
|
148
|
+
return
|
|
149
|
+
tokens = entry.get("tokens")
|
|
150
|
+
if not isinstance(tokens, dict):
|
|
151
|
+
return
|
|
152
|
+
for term in tokens:
|
|
153
|
+
remaining = df.get(term, 0) - 1
|
|
154
|
+
if remaining > 0:
|
|
155
|
+
df[term] = remaining
|
|
156
|
+
else:
|
|
157
|
+
df.pop(term, None)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _add_df(df: dict[str, int], tokens: dict[str, int]) -> None:
|
|
161
|
+
for term in tokens:
|
|
162
|
+
df[term] = df.get(term, 0) + 1
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _has_vectors(entry: dict[str, Any]) -> bool:
|
|
166
|
+
vectors = entry.get("vectors")
|
|
167
|
+
chunks = entry.get("chunks")
|
|
168
|
+
return (
|
|
169
|
+
isinstance(vectors, list)
|
|
170
|
+
and isinstance(chunks, list)
|
|
171
|
+
and len(vectors) == len(chunks)
|
|
172
|
+
and len(vectors) > 0
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _entry_chunks(entry: dict[str, Any]) -> list[tuple[int, str]] | None:
|
|
177
|
+
chunks = entry.get("chunks")
|
|
178
|
+
if not isinstance(chunks, list) or not chunks:
|
|
179
|
+
return None
|
|
180
|
+
result: list[tuple[int, str]] = []
|
|
181
|
+
for chunk in chunks:
|
|
182
|
+
if not isinstance(chunk, dict) or not isinstance(chunk.get("text"), str):
|
|
183
|
+
return None
|
|
184
|
+
try:
|
|
185
|
+
start = int(chunk.get("start") or 1)
|
|
186
|
+
except (TypeError, ValueError):
|
|
187
|
+
start = 1
|
|
188
|
+
result.append((max(1, start), chunk["text"]))
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _file_chunks(path: Path) -> list[tuple[int, str]] | None:
|
|
193
|
+
try:
|
|
194
|
+
data = path.read_bytes()
|
|
195
|
+
except OSError:
|
|
196
|
+
return None
|
|
197
|
+
if b"\x00" in data[:HEAD_BYTES]:
|
|
198
|
+
return None
|
|
199
|
+
return chunk_text(data.decode("utf-8", "replace"))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _make_embedder(settings: dict[str, Any]) -> Embedder:
|
|
203
|
+
embedding_settings = settings["embeddings"]
|
|
204
|
+
provider = str(embedding_settings["provider"])
|
|
205
|
+
model = str(embedding_settings["model"])
|
|
206
|
+
batch_size = int(embedding_settings["batch_size"])
|
|
207
|
+
|
|
208
|
+
def embedder(texts: list[str]) -> list[list[float]]:
|
|
209
|
+
return embeddings.embed_texts(
|
|
210
|
+
texts, provider, model, batch_size=batch_size
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return embedder
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _assign_vectors(
|
|
217
|
+
files: dict[str, dict[str, Any]],
|
|
218
|
+
pending: list[tuple[str, list[tuple[int, str]]]],
|
|
219
|
+
embed: Embedder,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Embed pending chunks and attach vectors, silently falling back."""
|
|
222
|
+
texts = [text for _rel, chunks in pending for _start, text in chunks]
|
|
223
|
+
try:
|
|
224
|
+
vectors = embed(texts)
|
|
225
|
+
except Exception:
|
|
226
|
+
vectors = []
|
|
227
|
+
if len(vectors) != len(texts):
|
|
228
|
+
for rel, _chunks in pending:
|
|
229
|
+
files[rel].pop("vectors", None)
|
|
230
|
+
return
|
|
231
|
+
offset = 0
|
|
232
|
+
for rel, chunks in pending:
|
|
233
|
+
count = len(chunks)
|
|
234
|
+
files[rel]["vectors"] = vectors[offset : offset + count]
|
|
235
|
+
offset += count
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def build_index(
|
|
239
|
+
workspace_root: Path,
|
|
240
|
+
*,
|
|
241
|
+
cfg: dict[str, Any] | None = None,
|
|
242
|
+
embedder: Embedder | None = None,
|
|
243
|
+
) -> BuildStats:
|
|
244
|
+
"""Incrementally update the index for ``workspace_root``.
|
|
245
|
+
|
|
246
|
+
``embedder`` overrides the configured embedding client (used by tests and
|
|
247
|
+
callers that already hold vectors). Embedding failures are swallowed: the
|
|
248
|
+
index simply stays lexical for those files. Never raises for a file that
|
|
249
|
+
disappears or cannot be read.
|
|
250
|
+
"""
|
|
251
|
+
root = Path(workspace_root).resolve()
|
|
252
|
+
settings = config.get_index(cfg)
|
|
253
|
+
if not settings["enabled"]:
|
|
254
|
+
return BuildStats()
|
|
255
|
+
with _BUILD_LOCK:
|
|
256
|
+
return _build(root, settings, embedder)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _build(
|
|
260
|
+
root: Path,
|
|
261
|
+
settings: dict[str, Any],
|
|
262
|
+
embedder: Embedder | None,
|
|
263
|
+
) -> BuildStats:
|
|
264
|
+
store = load_store(root)
|
|
265
|
+
files: dict[str, dict[str, Any]] = store["files"]
|
|
266
|
+
df: dict[str, int] = store["df"]
|
|
267
|
+
|
|
268
|
+
embed = embedder
|
|
269
|
+
if embed is None and embeddings_configured(settings):
|
|
270
|
+
embed = _make_embedder(settings)
|
|
271
|
+
|
|
272
|
+
max_files = int(settings["max_files"])
|
|
273
|
+
max_file_bytes = int(settings["max_file_bytes"])
|
|
274
|
+
collected, capped = _collect_files(root, max_files)
|
|
275
|
+
|
|
276
|
+
added = updated = removed = unchanged = 0
|
|
277
|
+
visited: set[str] = set()
|
|
278
|
+
fresh_chunks: dict[str, list[tuple[int, str]]] = {}
|
|
279
|
+
|
|
280
|
+
for file_p in collected:
|
|
281
|
+
rel = file_p.relative_to(root).as_posix()
|
|
282
|
+
visited.add(rel)
|
|
283
|
+
try:
|
|
284
|
+
stat = file_p.stat()
|
|
285
|
+
except OSError:
|
|
286
|
+
continue
|
|
287
|
+
if stat.st_size > max_file_bytes:
|
|
288
|
+
if rel in files:
|
|
289
|
+
_remove_entry(files, df, rel)
|
|
290
|
+
removed += 1
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
old = files.get(rel)
|
|
294
|
+
if (
|
|
295
|
+
old is not None
|
|
296
|
+
and old.get("mtime") == stat.st_mtime
|
|
297
|
+
and old.get("size") == stat.st_size
|
|
298
|
+
and isinstance(old.get("tokens"), dict)
|
|
299
|
+
):
|
|
300
|
+
unchanged += 1
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
if _is_binary(file_p):
|
|
304
|
+
if old is not None:
|
|
305
|
+
_remove_entry(files, df, rel)
|
|
306
|
+
removed += 1
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
text = file_p.read_text(encoding="utf-8", errors="replace")
|
|
311
|
+
except OSError:
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
if old is not None:
|
|
315
|
+
_remove_entry(files, df, rel)
|
|
316
|
+
updated += 1
|
|
317
|
+
else:
|
|
318
|
+
added += 1
|
|
319
|
+
|
|
320
|
+
tokens = count_tokens(text)
|
|
321
|
+
entry: dict[str, Any] = {
|
|
322
|
+
"mtime": stat.st_mtime,
|
|
323
|
+
"size": stat.st_size,
|
|
324
|
+
"tokens": tokens,
|
|
325
|
+
}
|
|
326
|
+
if embed is not None:
|
|
327
|
+
chunks = chunk_text(text)
|
|
328
|
+
entry["chunks"] = [
|
|
329
|
+
{"start": start, "text": chunk} for start, chunk in chunks
|
|
330
|
+
]
|
|
331
|
+
fresh_chunks[rel] = chunks
|
|
332
|
+
files[rel] = entry
|
|
333
|
+
_add_df(df, tokens)
|
|
334
|
+
|
|
335
|
+
if not capped:
|
|
336
|
+
# Only prune files we actually visited: when the file cap stopped the
|
|
337
|
+
# walk early, untouched entries may still exist further down the tree.
|
|
338
|
+
for rel in [name for name in files if name not in visited]:
|
|
339
|
+
_remove_entry(files, df, rel)
|
|
340
|
+
removed += 1
|
|
341
|
+
|
|
342
|
+
pending: list[tuple[str, list[tuple[int, str]]]] = []
|
|
343
|
+
if embed is not None:
|
|
344
|
+
for rel, entry in files.items():
|
|
345
|
+
if _has_vectors(entry):
|
|
346
|
+
continue
|
|
347
|
+
stored_chunks: list[tuple[int, str]] | None = fresh_chunks.get(
|
|
348
|
+
rel
|
|
349
|
+
) or _entry_chunks(entry)
|
|
350
|
+
if stored_chunks is None:
|
|
351
|
+
stored_chunks = _file_chunks(root / rel)
|
|
352
|
+
if stored_chunks is None:
|
|
353
|
+
continue
|
|
354
|
+
entry["chunks"] = [
|
|
355
|
+
{"start": start, "text": chunk}
|
|
356
|
+
for start, chunk in stored_chunks
|
|
357
|
+
]
|
|
358
|
+
if not stored_chunks:
|
|
359
|
+
continue
|
|
360
|
+
pending.append((rel, stored_chunks))
|
|
361
|
+
if pending:
|
|
362
|
+
_assign_vectors(files, pending, embed)
|
|
363
|
+
|
|
364
|
+
if added or updated or removed or pending or store.get("built_at") is None:
|
|
365
|
+
try:
|
|
366
|
+
save_store(root, store)
|
|
367
|
+
except OSError:
|
|
368
|
+
pass # the index is a cache; never fail a build over a write
|
|
369
|
+
return BuildStats(
|
|
370
|
+
added=added,
|
|
371
|
+
updated=updated,
|
|
372
|
+
removed=removed,
|
|
373
|
+
unchanged=unchanged,
|
|
374
|
+
files=len(files),
|
|
375
|
+
terms=len(df),
|
|
376
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Optional OpenAI-compatible embeddings for semantic search.
|
|
2
|
+
|
|
3
|
+
Embeddings are an enhancement, never a requirement: any failure raises
|
|
4
|
+
:class:`EmbeddingError`, which callers turn into a lexical-only fallback so a
|
|
5
|
+
bad key or an offline provider can never break a search.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from kiwimatecoder import config, network
|
|
16
|
+
from kiwimatecoder.providers import ProviderConfig
|
|
17
|
+
|
|
18
|
+
DEFAULT_TIMEOUT = 30.0
|
|
19
|
+
MAX_BATCH_SIZE = 256
|
|
20
|
+
MAX_ERROR_DETAIL = 200
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EmbeddingError(Exception):
|
|
24
|
+
"""A user-facing embedding failure with a ready-to-show message."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cosine(left: Sequence[float], right: Sequence[float]) -> float:
|
|
28
|
+
"""Return the cosine similarity of two vectors (0.0 when either is empty)."""
|
|
29
|
+
size = min(len(left), len(right))
|
|
30
|
+
if size == 0:
|
|
31
|
+
return 0.0
|
|
32
|
+
dot = sum(float(left[i]) * float(right[i]) for i in range(size))
|
|
33
|
+
norm_left = math.sqrt(sum(float(left[i]) ** 2 for i in range(size)))
|
|
34
|
+
norm_right = math.sqrt(sum(float(right[i]) ** 2 for i in range(size)))
|
|
35
|
+
if norm_left == 0.0 or norm_right == 0.0:
|
|
36
|
+
return 0.0
|
|
37
|
+
return dot / (norm_left * norm_right)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _provider_and_key(provider_id: str) -> tuple[ProviderConfig, str | None]:
|
|
41
|
+
try:
|
|
42
|
+
provider = config.get_provider_config(provider_id)
|
|
43
|
+
except KeyError as exc:
|
|
44
|
+
raise EmbeddingError(str(exc)) from exc
|
|
45
|
+
if provider.compat != "openai":
|
|
46
|
+
raise EmbeddingError(
|
|
47
|
+
f"Provider '{provider_id}' is not OpenAI-compatible; semantic "
|
|
48
|
+
"embeddings need a provider that serves POST /embeddings."
|
|
49
|
+
)
|
|
50
|
+
api_key = config.get_key(provider_id)
|
|
51
|
+
if provider.needs_key and not api_key:
|
|
52
|
+
raise EmbeddingError(
|
|
53
|
+
f"No API key for {provider.name} ({provider.key_env}) to embed with."
|
|
54
|
+
)
|
|
55
|
+
return provider, api_key
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _vector_batch(payload: object, expected: int) -> list[list[float]]:
|
|
59
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
60
|
+
if not isinstance(data, list) or len(data) != expected:
|
|
61
|
+
raise EmbeddingError(
|
|
62
|
+
"Embedding provider returned an unexpected number of vectors."
|
|
63
|
+
)
|
|
64
|
+
ordered = sorted(
|
|
65
|
+
(item for item in data if isinstance(item, dict)),
|
|
66
|
+
key=lambda item: int(item.get("index") or 0),
|
|
67
|
+
)
|
|
68
|
+
vectors: list[list[float]] = []
|
|
69
|
+
for item in ordered:
|
|
70
|
+
vector = item.get("embedding")
|
|
71
|
+
if not isinstance(vector, list):
|
|
72
|
+
raise EmbeddingError("Embedding provider returned a malformed vector.")
|
|
73
|
+
try:
|
|
74
|
+
vectors.append([float(value) for value in vector])
|
|
75
|
+
except (TypeError, ValueError) as exc:
|
|
76
|
+
raise EmbeddingError(
|
|
77
|
+
"Embedding provider returned a malformed vector."
|
|
78
|
+
) from exc
|
|
79
|
+
return vectors
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def embed_texts(
|
|
83
|
+
texts: Sequence[str],
|
|
84
|
+
provider_id: str,
|
|
85
|
+
model: str,
|
|
86
|
+
*,
|
|
87
|
+
batch_size: int | None = None,
|
|
88
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
89
|
+
transport: httpx.BaseTransport | None = None,
|
|
90
|
+
) -> list[list[float]]:
|
|
91
|
+
"""Embed ``texts`` with an OpenAI-compatible provider.
|
|
92
|
+
|
|
93
|
+
Requests are issued in bounded batches and results come back in input
|
|
94
|
+
order. Raises :class:`EmbeddingError` for a missing provider/model/key, an
|
|
95
|
+
unsupported provider, an HTTP error, or a malformed response.
|
|
96
|
+
"""
|
|
97
|
+
if not texts:
|
|
98
|
+
return []
|
|
99
|
+
provider_id = str(provider_id or "").strip()
|
|
100
|
+
model = str(model or "").strip()
|
|
101
|
+
if not provider_id or not model:
|
|
102
|
+
raise EmbeddingError("Embeddings need both a provider and a model.")
|
|
103
|
+
provider, api_key = _provider_and_key(provider_id)
|
|
104
|
+
if not provider.is_local and network.offline_enabled():
|
|
105
|
+
raise EmbeddingError(network.offline_message("embeddings"))
|
|
106
|
+
|
|
107
|
+
if batch_size is None:
|
|
108
|
+
batch_size = int(config.get_index()["embeddings"]["batch_size"])
|
|
109
|
+
size = max(1, min(int(batch_size), MAX_BATCH_SIZE))
|
|
110
|
+
|
|
111
|
+
headers = {"Content-Type": "application/json", **provider.extra_headers}
|
|
112
|
+
if api_key:
|
|
113
|
+
headers[provider.key_header] = f"{provider.key_prefix}{api_key}"
|
|
114
|
+
url = provider.versioned_url(f"{provider.base_url.rstrip('/')}/embeddings")
|
|
115
|
+
|
|
116
|
+
vectors: list[list[float]] = []
|
|
117
|
+
try:
|
|
118
|
+
with httpx.Client(
|
|
119
|
+
timeout=timeout,
|
|
120
|
+
transport=transport,
|
|
121
|
+
**network.current_options(),
|
|
122
|
+
) as client:
|
|
123
|
+
for start in range(0, len(texts), size):
|
|
124
|
+
batch = [str(text) for text in texts[start : start + size]]
|
|
125
|
+
response = client.post(
|
|
126
|
+
url, json={"model": model, "input": batch}, headers=headers
|
|
127
|
+
)
|
|
128
|
+
if response.status_code >= 400:
|
|
129
|
+
detail = response.text.strip()
|
|
130
|
+
if len(detail) > MAX_ERROR_DETAIL:
|
|
131
|
+
detail = detail[: MAX_ERROR_DETAIL - 3] + "..."
|
|
132
|
+
message = f"Embedding request failed with HTTP {response.status_code}"
|
|
133
|
+
raise EmbeddingError(message + (f": {detail}" if detail else "."))
|
|
134
|
+
try:
|
|
135
|
+
payload = response.json()
|
|
136
|
+
except ValueError as exc:
|
|
137
|
+
raise EmbeddingError(
|
|
138
|
+
"Embedding provider returned invalid JSON."
|
|
139
|
+
) from exc
|
|
140
|
+
vectors.extend(_vector_batch(payload, len(batch)))
|
|
141
|
+
except EmbeddingError:
|
|
142
|
+
raise
|
|
143
|
+
except httpx.HTTPError as exc:
|
|
144
|
+
raise EmbeddingError(
|
|
145
|
+
f"Embedding request failed: {exc.__class__.__name__}."
|
|
146
|
+
) from exc
|
|
147
|
+
if len(vectors) != len(texts):
|
|
148
|
+
raise EmbeddingError(
|
|
149
|
+
"Embedding provider returned an unexpected number of vectors."
|
|
150
|
+
)
|
|
151
|
+
return vectors
|