@allansantos-dev/smart-tool 0.9.40 → 0.9.41
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/CHANGELOG.md +24 -0
- package/README.md +3 -3
- package/config.py +1 -1
- package/dependency_notice.py +136 -32
- package/freshness.py +16 -10
- package/hook_decision.py +9 -7
- package/index_scope.py +9 -1
- package/indexer.py +4 -2
- package/model_breaker.py +70 -0
- package/model_client.py +17 -10
- package/model_health.py +46 -4
- package/package.json +1 -1
- package/project_monitor.py +17 -2
- package/setup_ui.py +32 -7
- package/smart_tool_daemon.py +120 -64
- package/usage_meter.py +13 -0
- package/version.py +1 -1
- package/web_fetch.py +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.41 - beta
|
|
4
|
+
|
|
5
|
+
- Far fewer calls to the embedding and rerank models. Measured on a real machine (24 h): 8303 embedding and 1473 rerank
|
|
6
|
+
calls, 0.3% of them failing; the volume came from four places, all cut: (1) an indexing job no longer embeds a fixed
|
|
7
|
+
text to learn the vector size when the index already stores it for the same model (1300 calls a day); (2) embedding
|
|
8
|
+
batches carry up to 128 chunks and 120,000 characters instead of 32,000 characters (3.8 chunks per call before); a
|
|
9
|
+
request the gateway refuses for its size is split in two halves once; (3) one rerank call per search instead of one
|
|
10
|
+
per block (code, test, doc); the blocks keep the order the separate calls gave; (4) a project in `eager` mode whose
|
|
11
|
+
files change all the time waits longer between jobs (60, 120, 240, 480 up to 900 s, back to 60 s after a quiet
|
|
12
|
+
period), and `*.tmp`, `*.bak`, `*.swp` and `*~` files no longer mark a project as changed or enter the index.
|
|
13
|
+
Measurement before and after in `docs/research/data/call-volume-*.json`, findings and sources in
|
|
14
|
+
`docs/research/2026-10-27-excesso-de-chamadas-embed-rerank.md`. Tests in `tests/test_call_volume.py`.
|
|
15
|
+
- A model that answers 429 or 503 three times in a row opens a circuit for 30 s: calls to it fail at once and the search
|
|
16
|
+
answers from the lexical index with the usual warning, instead of repeating requests. At most 6 requests are in
|
|
17
|
+
flight per model. Rerank and the model check wait the gateway's `Retry-After` (or a jittered exponential delay) before
|
|
18
|
+
asking again, like embeddings.
|
|
19
|
+
- The setup Models page groups the failures by type (429, 503, other 5xx, timeout, connection, authentication, other):
|
|
20
|
+
one row per type and role with the count, first and last time, one sample message, and the latest 20 lines on
|
|
21
|
+
expand. The summary shows the calls per purpose (indexing, query, rerank, model check). `dependency_mode` has its own
|
|
22
|
+
card in the setup rules.
|
|
23
|
+
- The dependency notice also reads `requirements*.txt` (PyPI, `==` pins and the lower bound of `>=` and `~=`) and `go.mod`
|
|
24
|
+
(Go, `require` lines with a plain `vX.Y.Z`). `web_fetch` reads PDFs with the same page and character caps as the
|
|
25
|
+
indexer (`document_text`).
|
|
26
|
+
|
|
3
27
|
## 0.9.40 - beta
|
|
4
28
|
|
|
5
29
|
- PDFs of the project are searchable. `smart_search` indexes the text layer of a `.pdf` inside the project scope and
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ registered as the `playwright` server where this machine lets a browser be drive
|
|
|
16
16
|
An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
|
|
17
17
|
are cheaper, or just tells the agent what Smart Tool would do.
|
|
18
18
|
|
|
19
|
-
**Status:** 0.9.
|
|
19
|
+
**Status:** 0.9.41 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
|
|
20
20
|
|
|
21
21
|
## How it works
|
|
22
22
|
|
|
@@ -129,7 +129,7 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
129
129
|
When a new version recommends other models than the ones you use, a Windows notification (**Apply now**, **Don't
|
|
130
130
|
show again**) and the **New recommended models** card on this page offer them: nothing changes until you apply, the
|
|
131
131
|
models you had are kept, and **Switch back to my previous models** undoes it. The **Model failures (last 24 h)** table
|
|
132
|
-
|
|
132
|
+
groups the failed gateway calls by type (429, 503, other 5xx, timeout, connection, authentication) and role, with the count, the first and last time and one sample, and shows the calls per purpose; a model that fails half of its calls (5 or more) also raises a
|
|
133
133
|
notification and a tray alert. A failure inside a tool call also comes back as a warning line in the tool answer;
|
|
134
134
|
calls made by a hook or by background work (index scope and profile, block-pattern proposals) have no answer to
|
|
135
135
|
carry it, so they show only in the table, the log and the notification. The local embedder is not in that table or in
|
|
@@ -189,7 +189,7 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
189
189
|
A documented function edited only in its body is not mentioned. `project_manage` with `action=docs` lists the
|
|
190
190
|
public functions still without a docstring, for documenting a project that started without them.
|
|
191
191
|
|
|
192
|
-
**Dependency check on edit**: an edit of `package.json` that adds or changes a dependency lists the OSV advisory IDs of up to 5 of them (exact version, or the lower bound of `^`,
|
|
192
|
+
**Dependency check on edit**: an edit of `package.json`, `requirements*.txt` or `go.mod` that adds or changes a dependency lists the OSV advisory IDs of up to 5 of them (exact version, or the lower bound of `^`, `~`, `~=` and `>=`), answered from a 6 h disk cache or one 2.5 s batch call that sends only package names and versions; when OSV cannot answer, the reminder says the check was skipped and the edit goes on. `dependency_mode` in `config.json`: `warn` (default) or `off`.
|
|
193
193
|
|
|
194
194
|
**PDFs** inside a project are indexed as text with `[page N]` markers (20 MiB, 300 pages and 20 s per file; a scanned, password-protected or corrupt PDF is skipped with the reason), so `smart_search` returns the page that holds a passage.
|
|
195
195
|
|
package/config.py
CHANGED
|
@@ -62,7 +62,7 @@ DOC_MODES = ("require", "remind", "off")
|
|
|
62
62
|
# Edit hook on functions that copy one already indexed (exact or near-identical body): warn = let the edit run and
|
|
63
63
|
# point to the existing function; off = say nothing. Never blocks: near matches are leads, not defects.
|
|
64
64
|
DUPLICATE_MODES = ("warn", "off")
|
|
65
|
-
# Edit hook on package.json: warn = let the edit run and list the added or changed dependencies that have OSV advisories;
|
|
65
|
+
# Edit hook on package.json, requirements*.txt and go.mod: warn = let the edit run and list the added or changed dependencies that have OSV advisories;
|
|
66
66
|
# off = say nothing and make no network call.
|
|
67
67
|
DEPENDENCY_MODES = ("warn", "off")
|
|
68
68
|
# Router model reviewing, in the background, searches the deterministic redirect rule let run: on = propose block
|
package/dependency_notice.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
"""Vulnerable-dependency notice for an edit of
|
|
1
|
+
"""Vulnerable-dependency notice for an edit of a dependency manifest, built before the edit runs.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Manifests: package.json (OSV ecosystem npm), requirements*.txt (PyPI: `==` pins and the lower bound of `>=` and `~=`)
|
|
4
|
+
and go.mod (Go: `require` lines with a plain vX.Y.Z). Compares the dependencies of the file before and after the edit,
|
|
5
|
+
asks OSV (one `querybatch` call, short timeout) about the added or changed ones only, and answers from a disk cache of
|
|
6
|
+
(ecosystem, package, version) -> advisory IDs for 6 hours. Only package names and versions leave the machine. The caller (`hook_decision`) turns a `DependencyCheckError` into
|
|
6
7
|
"dependency check skipped: <reason>" and lets the edit go on.
|
|
7
8
|
"""
|
|
9
|
+
import collections
|
|
8
10
|
import json
|
|
9
11
|
import os
|
|
10
12
|
import re
|
|
@@ -23,13 +25,17 @@ TABLES = ("dependencies", "devDependencies", "peerDependencies", "optionalDepend
|
|
|
23
25
|
PREFIX = "Smart Tool dependencies: "
|
|
24
26
|
_SPECIFIER_RE = re.compile(r"(\^|~|>=|>|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?"
|
|
25
27
|
r"((?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)")
|
|
28
|
+
_REQUIREMENTS_FILE_RE = re.compile(r"requirements[A-Za-z0-9._-]*\.txt")
|
|
29
|
+
_REQUIREMENT_RE = re.compile(r"([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*([^;]*)")
|
|
30
|
+
_PIP_VERSION_RE = re.compile(r"[0-9][0-9A-Za-z.+!_-]*")
|
|
31
|
+
_GO_VERSION_RE = re.compile(r"v\d+\.\d+\.\d+")
|
|
26
32
|
|
|
27
33
|
|
|
28
34
|
class DependencyCheckError(RuntimeError):
|
|
29
35
|
"""The check could not run (OSV unreachable or answering badly, unreadable cache, current file not JSON)."""
|
|
30
36
|
|
|
31
37
|
|
|
32
|
-
def
|
|
38
|
+
def _npm_pairs(text):
|
|
33
39
|
"""The (name, specifier) pairs of every dependency table in a package.json text.
|
|
34
40
|
|
|
35
41
|
Parameters: text (str) file content.
|
|
@@ -49,32 +55,91 @@ def _tables(text):
|
|
|
49
55
|
return pairs
|
|
50
56
|
|
|
51
57
|
|
|
52
|
-
def
|
|
53
|
-
"""
|
|
58
|
+
def _requirements_pairs(text):
|
|
59
|
+
"""The (name, specifier) pairs of a requirements.txt text; options, includes, editable and URL lines are skipped.
|
|
54
60
|
|
|
55
|
-
Parameters:
|
|
56
|
-
Returns:
|
|
57
|
-
|
|
61
|
+
Parameters: text (str) file content.
|
|
62
|
+
Returns: dict (name, specifier) -> None in file order; the specifier is the version clauses without spaces ("" when
|
|
63
|
+
the line only names the package).
|
|
64
|
+
"""
|
|
65
|
+
pairs = {}
|
|
66
|
+
for raw in text.splitlines():
|
|
67
|
+
line = raw.split(" #")[0].split(";")[0].strip().rstrip("\\").strip()
|
|
68
|
+
if not line or line.startswith(("#", "-")):
|
|
69
|
+
continue
|
|
70
|
+
line = " ".join(word for word in line.split() if not word.startswith("--"))
|
|
71
|
+
match = _REQUIREMENT_RE.fullmatch(line)
|
|
72
|
+
if not match:
|
|
73
|
+
continue
|
|
74
|
+
specifier = "".join(match.group(2).split())
|
|
75
|
+
if specifier and specifier[0] not in "=<>!~":
|
|
76
|
+
continue
|
|
77
|
+
pairs[(match.group(1), specifier)] = None
|
|
78
|
+
return pairs
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _go_pairs(text):
|
|
82
|
+
"""The (module, version) pairs of the `require` lines of a go.mod text, single-line and block; replace, exclude and
|
|
83
|
+
the other directives are skipped.
|
|
84
|
+
|
|
85
|
+
Parameters: text (str) file content.
|
|
86
|
+
Returns: dict (module, version) -> None in file order.
|
|
58
87
|
"""
|
|
59
|
-
|
|
88
|
+
pairs, block = {}, None
|
|
89
|
+
for raw in text.splitlines():
|
|
90
|
+
tokens = raw.split("//")[0].split()
|
|
91
|
+
if not tokens:
|
|
92
|
+
continue
|
|
93
|
+
if block is None:
|
|
94
|
+
if tokens[0] == "require" and len(tokens) == 3:
|
|
95
|
+
pairs[(tokens[1], tokens[2])] = None
|
|
96
|
+
elif tokens[-1] == "(":
|
|
97
|
+
block = tokens[0]
|
|
98
|
+
elif tokens == [")"]:
|
|
99
|
+
block = None
|
|
100
|
+
elif block == "require" and len(tokens) == 2:
|
|
101
|
+
pairs[(tokens[0], tokens[1])] = None
|
|
102
|
+
return pairs
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def ecosystem_of(path):
|
|
106
|
+
"""The OSV ecosystem of a manifest file name.
|
|
107
|
+
|
|
108
|
+
Parameters: path (str) file path.
|
|
109
|
+
Returns: "npm" for package.json, "PyPI" for requirements*.txt, "Go" for go.mod; None for any other file.
|
|
110
|
+
"""
|
|
111
|
+
name = os.path.basename(path)
|
|
112
|
+
if name == "package.json":
|
|
113
|
+
return "npm"
|
|
114
|
+
if name == "go.mod":
|
|
115
|
+
return "Go"
|
|
116
|
+
return "PyPI" if _REQUIREMENTS_FILE_RE.fullmatch(name) else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def changed_dependencies(before, after, ecosystem="npm"):
|
|
120
|
+
"""Dependencies added or whose specifier changed between two manifest texts.
|
|
121
|
+
|
|
122
|
+
Parameters: before (str or None) text before the edit, None when the file did not exist; after (str) text after;
|
|
123
|
+
ecosystem (str) one of ECOSYSTEMS.
|
|
124
|
+
Returns: list of (name, specifier); empty when `after` is a package.json that is not valid JSON (mid-edit).
|
|
125
|
+
Raises: DependencyCheckError when `before` exists but is a package.json that is not valid JSON.
|
|
126
|
+
"""
|
|
127
|
+
parse = _ECOSYSTEMS[ecosystem].parse
|
|
128
|
+
now = parse(after)
|
|
60
129
|
if now is None:
|
|
61
130
|
return []
|
|
62
131
|
if before is None or not before.strip():
|
|
63
132
|
old = {}
|
|
64
133
|
else:
|
|
65
|
-
old =
|
|
134
|
+
old = parse(before)
|
|
66
135
|
if old is None:
|
|
67
136
|
raise DependencyCheckError("current package.json is not valid JSON")
|
|
68
137
|
return [pair for pair in now if pair not in old]
|
|
69
138
|
|
|
70
139
|
|
|
71
|
-
def
|
|
72
|
-
"""The version
|
|
73
|
-
|
|
74
|
-
Parameters: specifier (str) the value in the dependency table.
|
|
75
|
-
Returns: str, the exact version or the lower bound of a ^, ~, >= or > range; None when the specifier cannot be
|
|
76
|
-
reduced safely (tags, `*`, URLs, file:, git, workspace:, npm: aliases, x-ranges, compound ranges).
|
|
77
|
-
"""
|
|
140
|
+
def _npm_version(specifier):
|
|
141
|
+
"""The npm case of `queried_version`: the exact version or the lower bound of a ^, ~, >= or > range; None for tags,
|
|
142
|
+
`*`, URLs, file:, git, workspace:, npm: aliases, x-ranges and compound ranges."""
|
|
78
143
|
match = _SPECIFIER_RE.fullmatch(specifier.strip())
|
|
79
144
|
if not match:
|
|
80
145
|
return None
|
|
@@ -87,6 +152,42 @@ def queried_version(specifier):
|
|
|
87
152
|
return ".".join(parts) + (tail or "")
|
|
88
153
|
|
|
89
154
|
|
|
155
|
+
def _pip_version(specifier):
|
|
156
|
+
"""The PyPI case of `queried_version`: the `==` version, else the first `>=` or `~=` lower bound; None for no
|
|
157
|
+
specifier, `===`, wildcards, `<`, `>`, `!=` alone and anything that is not a version."""
|
|
158
|
+
clauses = specifier.split(",")
|
|
159
|
+
for operators in (("==",), (">=", "~=")):
|
|
160
|
+
for clause in clauses:
|
|
161
|
+
for operator in operators:
|
|
162
|
+
if clause.startswith(operator) and _PIP_VERSION_RE.fullmatch(clause[len(operator):]):
|
|
163
|
+
return clause[len(operator):]
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _go_version(specifier):
|
|
168
|
+
"""The Go case of `queried_version`: a plain vX.Y.Z; None for pseudo-versions, pre-releases and +incompatible."""
|
|
169
|
+
return specifier if _GO_VERSION_RE.fullmatch(specifier) else None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def queried_version(specifier, ecosystem="npm"):
|
|
173
|
+
"""The version to ask OSV about for a specifier.
|
|
174
|
+
|
|
175
|
+
Parameters: specifier (str) the value in the dependency table; ecosystem (str) one of ECOSYSTEMS.
|
|
176
|
+
Returns: str, the exact version or the lower bound of the range; None when the specifier cannot be reduced safely.
|
|
177
|
+
"""
|
|
178
|
+
return _ECOSYSTEMS[ecosystem].version(specifier)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
_Ecosystem = collections.namedtuple("_Ecosystem", "parse version label exact")
|
|
182
|
+
_ECOSYSTEMS = {
|
|
183
|
+
"npm": _Ecosystem(_npm_pairs, _npm_version, lambda name, spec: f"{name}@{spec}", lambda spec, version: spec == version),
|
|
184
|
+
"PyPI": _Ecosystem(_requirements_pairs, _pip_version, lambda name, spec: f"{name}{spec}",
|
|
185
|
+
lambda spec, version: spec == "==" + version),
|
|
186
|
+
"Go": _Ecosystem(_go_pairs, _go_version, lambda name, spec: f"{name}@{spec}", lambda spec, version: spec == version),
|
|
187
|
+
}
|
|
188
|
+
ECOSYSTEMS = tuple(_ECOSYSTEMS)
|
|
189
|
+
|
|
190
|
+
|
|
90
191
|
def _load_cache():
|
|
91
192
|
"""The cache as {"name@version": {"ids": [...], "at": epoch}}; empty when the file does not exist.
|
|
92
193
|
|
|
@@ -111,26 +212,27 @@ def _fresh_ids(entry, now):
|
|
|
111
212
|
return entry["ids"] if now - entry["at"] < CACHE_TTL_S else None
|
|
112
213
|
|
|
113
214
|
|
|
114
|
-
def _advisories(queries):
|
|
215
|
+
def _advisories(ecosystem, queries):
|
|
115
216
|
"""Advisory IDs for (name, version) pairs, from the cache and one OSV batch for the rest.
|
|
116
217
|
|
|
117
|
-
Parameters: queries (list of (name, version)).
|
|
218
|
+
Parameters: ecosystem (str) one of ECOSYSTEMS; queries (list of (name, version)).
|
|
118
219
|
Returns: tuple (list of IDs or None per query in order, str cache-write problem or "").
|
|
119
220
|
Raises: DependencyCheckError when the cache is corrupt or OSV cannot answer.
|
|
120
221
|
"""
|
|
121
222
|
cache, now = _load_cache(), time.time()
|
|
122
|
-
|
|
223
|
+
key = lambda query: f"{ecosystem}:{query[0]}@{query[1]}" # noqa: E731
|
|
224
|
+
found = {query: _fresh_ids(cache.get(key(query)), now) for query in queries}
|
|
123
225
|
missing = [query for query in queries if found[query] is None]
|
|
124
226
|
if not missing:
|
|
125
227
|
return [found[query] for query in queries], ""
|
|
126
228
|
try:
|
|
127
|
-
answered = freshness.
|
|
229
|
+
answered = freshness.advisories_batch(ecosystem, missing, OSV_TIMEOUT_S)
|
|
128
230
|
except RuntimeError as exc:
|
|
129
231
|
raise DependencyCheckError(str(exc)) from exc
|
|
130
232
|
for query, ids in zip(missing, answered):
|
|
131
233
|
found[query] = ids
|
|
132
234
|
if ids is not None:
|
|
133
|
-
cache[
|
|
235
|
+
cache[key(query)] = {"ids": ids, "at": now}
|
|
134
236
|
cache = {key: entry for key, entry in cache.items() if _fresh_ids(entry, now) is not None}
|
|
135
237
|
try:
|
|
136
238
|
os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
|
|
@@ -140,28 +242,30 @@ def _advisories(queries):
|
|
|
140
242
|
return [found[query] for query in queries], ""
|
|
141
243
|
|
|
142
244
|
|
|
143
|
-
def notice(before, after):
|
|
144
|
-
"""The dependency notice for one
|
|
245
|
+
def notice(before, after, ecosystem="npm"):
|
|
246
|
+
"""The dependency notice for one manifest edit.
|
|
145
247
|
|
|
146
|
-
Parameters: before (str or None) file text before the edit; after (str) text after it
|
|
248
|
+
Parameters: before (str or None) file text before the edit; after (str) text after it; ecosystem (str) one of
|
|
249
|
+
ECOSYSTEMS, from `ecosystem_of`.
|
|
147
250
|
Returns: str, "Smart Tool dependencies: ..." lines for the changed dependencies that have advisories or could not
|
|
148
251
|
be verified, "" when there is nothing to say.
|
|
149
252
|
Raises: DependencyCheckError when the check could not run.
|
|
150
253
|
"""
|
|
151
|
-
|
|
254
|
+
spec = _ECOSYSTEMS[ecosystem]
|
|
255
|
+
changed = changed_dependencies(before, after, ecosystem)
|
|
152
256
|
handled, extra = changed[:MAX_PACKAGES], len(changed) - MAX_PACKAGES
|
|
153
257
|
lines, queries = {}, []
|
|
154
258
|
for name, specifier in handled:
|
|
155
|
-
version =
|
|
259
|
+
version = spec.version(specifier) if freshness.NAME_RES[ecosystem].fullmatch(name) else None
|
|
156
260
|
if version is None:
|
|
157
|
-
lines[(name, specifier)] = f"`{name
|
|
261
|
+
lines[(name, specifier)] = f"`{spec.label(name, specifier)}` specifier not verified."
|
|
158
262
|
else:
|
|
159
263
|
queries.append((name, specifier, version))
|
|
160
264
|
problem = ""
|
|
161
265
|
if queries:
|
|
162
|
-
ids, problem = _advisories([(name, version) for name, _specifier, version in queries])
|
|
266
|
+
ids, problem = _advisories(ecosystem, [(name, version) for name, _specifier, version in queries])
|
|
163
267
|
for (name, specifier, version), advisories in zip(queries, ids):
|
|
164
|
-
label = f"`{name
|
|
268
|
+
label = f"`{spec.label(name, specifier)}`" + ("" if spec.exact(specifier, version) else f" (checked {version})")
|
|
165
269
|
if advisories is None:
|
|
166
270
|
lines[(name, specifier)] = f"{label} advisory check incomplete, not verified."
|
|
167
271
|
elif advisories:
|
package/freshness.py
CHANGED
|
@@ -73,11 +73,13 @@ def npm_registry_evidence(package):
|
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
NPM_NAME_RE = re.compile(r"(?:@[a-z0-9._-]+/)?[a-z0-9._-]+")
|
|
76
|
+
NAME_RES = {"npm": NPM_NAME_RE, "PyPI": re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*"),
|
|
77
|
+
"Go": re.compile(r"[A-Za-z0-9][A-Za-z0-9._~/-]*")}
|
|
76
78
|
|
|
77
79
|
|
|
78
|
-
def _osv_query(package, package_version):
|
|
79
|
-
"""One OSV query object: the package
|
|
80
|
-
return {"package": {"name": package, "ecosystem":
|
|
80
|
+
def _osv_query(ecosystem, package, package_version):
|
|
81
|
+
"""One OSV query object: the package in `ecosystem` and the version asked about."""
|
|
82
|
+
return {"package": {"name": package, "ecosystem": ecosystem}, "version": package_version}
|
|
81
83
|
|
|
82
84
|
|
|
83
85
|
def _osv_request(endpoint, payload):
|
|
@@ -107,7 +109,7 @@ def npm_advisories(package, package_version):
|
|
|
107
109
|
"""IDs conhecidos pela OSV para a versão; None significa checagem indisponível."""
|
|
108
110
|
if not package or not package_version or not NPM_NAME_RE.fullmatch(package):
|
|
109
111
|
return None
|
|
110
|
-
request = _osv_request("query", _osv_query(package, package_version))
|
|
112
|
+
request = _osv_request("query", _osv_query("npm", package, package_version))
|
|
111
113
|
try:
|
|
112
114
|
with urllib.request.urlopen(request, timeout=7) as response:
|
|
113
115
|
return _osv_ids(json.load(response))
|
|
@@ -115,17 +117,21 @@ def npm_advisories(package, package_version):
|
|
|
115
117
|
return None
|
|
116
118
|
|
|
117
119
|
|
|
118
|
-
def
|
|
120
|
+
def advisories_batch(ecosystem, packages, timeout):
|
|
119
121
|
"""Advisory IDs for each (package, version) of `packages` from one OSV `querybatch` call.
|
|
120
122
|
|
|
121
|
-
Parameters: packages (list of (name, version)); timeout (float) seconds for
|
|
123
|
+
Parameters: ecosystem (str) "npm", "PyPI" or "Go"; packages (list of (name, version)); timeout (float) seconds for
|
|
124
|
+
the request.
|
|
122
125
|
Returns: list in the same order, each the sorted ID list or None when that package's answer is incomplete
|
|
123
126
|
(next_page_token) or malformed, so it is unverified and not clean.
|
|
124
|
-
Raises:
|
|
127
|
+
Raises: ValueError for an unknown ecosystem or an invalid name or version; RuntimeError when the service cannot be
|
|
128
|
+
reached, answers badly or answers a different number of results.
|
|
125
129
|
"""
|
|
126
|
-
if
|
|
127
|
-
raise ValueError(f"
|
|
128
|
-
|
|
130
|
+
if ecosystem not in NAME_RES:
|
|
131
|
+
raise ValueError(f"Unknown OSV ecosystem: {ecosystem!r}")
|
|
132
|
+
if any(not NAME_RES[ecosystem].fullmatch(name) or not package_version for name, package_version in packages):
|
|
133
|
+
raise ValueError(f"Invalid {ecosystem} package query: {packages!r}")
|
|
134
|
+
request = _osv_request("querybatch", {"queries": [_osv_query(ecosystem, name, package_version)
|
|
129
135
|
for name, package_version in packages]})
|
|
130
136
|
try:
|
|
131
137
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
package/hook_decision.py
CHANGED
|
@@ -230,20 +230,21 @@ def _edit_review(payload, doc_mode, duplicate_mode, dependency_mode):
|
|
|
230
230
|
"""Checks of an edit before it runs. Docstrings follow doc_mode, in code files of registered projects only (tests,
|
|
231
231
|
docs and files outside a project are left out, as in the docs coverage): missing ones deny the edit in require mode and become a reminder in remind mode; a
|
|
232
232
|
documented function whose signature changed always gets a reminder. Copies of indexed functions follow
|
|
233
|
-
duplicate_mode and only add context, never deny. An edit of a package.json follows dependency_mode: advisories
|
|
233
|
+
duplicate_mode and only add context, never deny. An edit of a package.json, requirements*.txt or go.mod follows dependency_mode: advisories
|
|
234
234
|
of the added or changed dependencies are added as context, never a denial."""
|
|
235
235
|
with code_graph.pending_scope():
|
|
236
236
|
return _edit_review_scoped(payload, doc_mode, duplicate_mode, dependency_mode)
|
|
237
237
|
|
|
238
238
|
|
|
239
|
-
def _dependency_part(before, after):
|
|
240
|
-
"""The dependency notice of one
|
|
239
|
+
def _dependency_part(before, after, ecosystem):
|
|
240
|
+
"""The dependency notice of one manifest edit, or the "dependency check skipped" line when it cannot run.
|
|
241
241
|
|
|
242
|
-
Parameters: before (str or None) text before the edit; after (str) text after it
|
|
242
|
+
Parameters: before (str or None) text before the edit; after (str) text after it; ecosystem (str) from
|
|
243
|
+
`dependency_notice.ecosystem_of`.
|
|
243
244
|
Returns: str, empty when there is nothing to say.
|
|
244
245
|
"""
|
|
245
246
|
try:
|
|
246
|
-
return dependency_notice.notice(before, after)
|
|
247
|
+
return dependency_notice.notice(before, after, ecosystem)
|
|
247
248
|
except dependency_notice.DependencyCheckError as exc:
|
|
248
249
|
return f"{dependency_notice.PREFIX}dependency check skipped: {exc}"
|
|
249
250
|
|
|
@@ -252,8 +253,9 @@ def _edit_review_scoped(payload, doc_mode, duplicate_mode, dependency_mode):
|
|
|
252
253
|
"""`_edit_review` inside one `code_graph.pending_scope`, so the pending-changes scan runs once per review."""
|
|
253
254
|
doc_findings, duplicate_findings, failures, dependency_parts = [], [], [], []
|
|
254
255
|
for path, before, after in edit_preview.preview(payload.get("tool_name"), payload.get("tool_input"), payload.get("cwd")):
|
|
255
|
-
|
|
256
|
-
|
|
256
|
+
ecosystem = dependency_notice.ecosystem_of(path)
|
|
257
|
+
if dependency_mode != "off" and ecosystem:
|
|
258
|
+
dependency_parts += [part for part in (_dependency_part(before, after, ecosystem),) if part]
|
|
257
259
|
continue
|
|
258
260
|
if not doc_check.supported(path):
|
|
259
261
|
continue
|
package/index_scope.py
CHANGED
|
@@ -40,10 +40,13 @@ _PROJECT_TYPE_EXCLUDES = (
|
|
|
40
40
|
(("Podfile",), ("Pods",)),
|
|
41
41
|
)
|
|
42
42
|
|
|
43
|
+
# Files a tool writes while it works and replaces or deletes right after (atomic-write temp, backup, editor swap).
|
|
44
|
+
TRANSIENT_FILES = {"*.tmp", "*.bak", "*.swp", "*~"}
|
|
45
|
+
|
|
43
46
|
# Mesmo critério do `_ALWAYS_EXCLUDE`, para arquivo: o significado não varia por
|
|
44
47
|
# projeto. Lockfile é grafo de dependência resolvido por ferramenta — nunca é fonte de
|
|
45
48
|
# resposta numa busca semântica, e é grande o bastante pra dominar o índice.
|
|
46
|
-
_ALWAYS_EXCLUDE_FILES = {
|
|
49
|
+
_ALWAYS_EXCLUDE_FILES = TRANSIENT_FILES | {
|
|
47
50
|
"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json",
|
|
48
51
|
"poetry.lock", "pdm.lock", "uv.lock", "Pipfile.lock", "Cargo.lock",
|
|
49
52
|
"composer.lock", "go.sum", "Gemfile.lock", "packages.lock.json",
|
|
@@ -110,6 +113,11 @@ def normalize_include(include):
|
|
|
110
113
|
return bases, dropped
|
|
111
114
|
|
|
112
115
|
|
|
116
|
+
def is_transient(name):
|
|
117
|
+
"""Whether a file name is a temporary, backup or editor-swap file (`*.tmp`, `*.bak`, `*.swp`, `*~`)."""
|
|
118
|
+
return any(fnmatch.fnmatch(name.casefold(), pattern) for pattern in TRANSIENT_FILES)
|
|
119
|
+
|
|
120
|
+
|
|
113
121
|
def always_excluded(name):
|
|
114
122
|
"""Directory name in _ALWAYS_EXCLUDE, entries may be globs (e.g. *.egg-info)."""
|
|
115
123
|
folded = name.casefold()
|
package/indexer.py
CHANGED
|
@@ -31,8 +31,10 @@ CHUNK_MAX_CHARS = 8000
|
|
|
31
31
|
CHUNK_OVERLAP_LINES = 12
|
|
32
32
|
CHUNKER_VERSION = 3
|
|
33
33
|
SCHEMA_VERSION = 2
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
# An embeddings request holds up to 128 items and 120,000 characters (about 30,000 tokens): far under the 2048 items and
|
|
35
|
+
# 300,000 tokens per request that OpenAI and Azure accept (docs/research/2026-10-27-excesso-de-chamadas-embed-rerank.md).
|
|
36
|
+
REINDEX_BATCH_MAX_CHARS = 120_000
|
|
37
|
+
REINDEX_BATCH_MAX_ITEMS = 128
|
|
36
38
|
# Embedding requests in flight per flush. Measured 2026-10-03 (docs/spikes/embed_parallel_spike.py, 30 batches,
|
|
37
39
|
# 3 interleaved rounds): 1 worker 33-50 s, 2 workers 19-27 s, 4 workers 11-20 s (~2.8x), no 429 from the gateway.
|
|
38
40
|
EMBED_WORKERS = 4
|
package/model_breaker.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Protection of the model gateway, per model id: a circuit that opens after consecutive 429/503 answers and a cap on
|
|
2
|
+
the requests in flight.
|
|
3
|
+
|
|
4
|
+
Pattern: a circuit breaker (closed, open for a pause, then one trial call decides). Retrying a gateway that answers
|
|
5
|
+
busy only adds load (AWS Bedrock throughput guide: honor Retry-After, back off with jitter); once it keeps answering
|
|
6
|
+
busy the caller stops asking for a while and serves what it can without the model.
|
|
7
|
+
"""
|
|
8
|
+
import contextlib
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
|
|
13
|
+
FAILURES_TO_OPEN = 3
|
|
14
|
+
PAUSE_S = 30
|
|
15
|
+
MAX_IN_FLIGHT = 6
|
|
16
|
+
_BUSY_CODES = (429, 503)
|
|
17
|
+
_lock = threading.Lock()
|
|
18
|
+
_failures = {}
|
|
19
|
+
_open_until = {}
|
|
20
|
+
_slots = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CircuitOpen(RuntimeError):
|
|
24
|
+
"""The model is paused after repeated busy answers; no request was sent."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def guard(model):
|
|
28
|
+
"""Raises CircuitOpen while `model` is paused. Nothing happens for a call without a model id."""
|
|
29
|
+
if not model:
|
|
30
|
+
return
|
|
31
|
+
with _lock:
|
|
32
|
+
left = _open_until.get(model, 0) - time.monotonic()
|
|
33
|
+
if left > 0:
|
|
34
|
+
raise CircuitOpen(f"Model {model} is paused for {int(left) + 1} s after {FAILURES_TO_OPEN} consecutive "
|
|
35
|
+
"HTTP 429/503 answers from the gateway; no request was sent.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def note(model, failure=None):
|
|
39
|
+
"""Records the outcome of a request: a success closes the circuit, a 429/503 counts toward opening it, any other
|
|
40
|
+
failure changes nothing. After the pause the first failure opens it again."""
|
|
41
|
+
if not model:
|
|
42
|
+
return
|
|
43
|
+
busy = isinstance(failure, urllib.error.HTTPError) and failure.code in _BUSY_CODES
|
|
44
|
+
with _lock:
|
|
45
|
+
if failure is None:
|
|
46
|
+
_failures[model] = 0
|
|
47
|
+
elif busy:
|
|
48
|
+
_failures[model] = _failures.get(model, 0) + 1
|
|
49
|
+
if _failures[model] >= FAILURES_TO_OPEN:
|
|
50
|
+
_open_until[model] = time.monotonic() + PAUSE_S
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@contextlib.contextmanager
|
|
54
|
+
def slot(model):
|
|
55
|
+
"""Holds one of the `MAX_IN_FLIGHT` places of `model` for the duration of a request."""
|
|
56
|
+
if not model:
|
|
57
|
+
yield
|
|
58
|
+
return
|
|
59
|
+
with _lock:
|
|
60
|
+
semaphore = _slots.setdefault(model, threading.BoundedSemaphore(MAX_IN_FLIGHT))
|
|
61
|
+
with semaphore:
|
|
62
|
+
yield
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def reset():
|
|
66
|
+
"""Forgets every circuit and cap (tests)."""
|
|
67
|
+
with _lock:
|
|
68
|
+
_failures.clear()
|
|
69
|
+
_open_until.clear()
|
|
70
|
+
_slots.clear()
|
package/model_client.py
CHANGED
|
@@ -9,6 +9,7 @@ import usage_meter
|
|
|
9
9
|
|
|
10
10
|
import config
|
|
11
11
|
import gateway
|
|
12
|
+
import model_breaker
|
|
12
13
|
import model_health
|
|
13
14
|
|
|
14
15
|
OFFLINE = contextvars.ContextVar("model_offline", default=None)
|
|
@@ -26,18 +27,24 @@ def get_token():
|
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
def fetch(path, token, method="GET", body=None, timeout=15):
|
|
30
|
+
"""One gateway request; returns the parsed JSON. A call that names a model first passes that model's circuit and
|
|
31
|
+
request cap (model_breaker, CircuitOpen when paused), and every attempt is metered. HTTP errors propagate."""
|
|
29
32
|
if OFFLINE.get():
|
|
30
33
|
raise RuntimeError(OFFLINE.get())
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
model=body.get("model") if isinstance(body,dict) else None
|
|
35
|
+
model_breaker.guard(model)
|
|
36
|
+
with model_breaker.slot(model):
|
|
37
|
+
started=time.monotonic();result=None;headers=None;failure=None
|
|
38
|
+
try:
|
|
39
|
+
result,headers=_fetch(path,token,method,body,timeout)
|
|
40
|
+
return result
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
failure=exc
|
|
43
|
+
raise
|
|
44
|
+
finally:
|
|
45
|
+
model_breaker.note(model,failure)
|
|
46
|
+
usage_meter.record(path,body,result,headers,failure,time.monotonic()-started)
|
|
47
|
+
model_health.record(path,body,failure)
|
|
41
48
|
|
|
42
49
|
|
|
43
50
|
def _fetch(path, _token, method="GET", body=None, timeout=15):
|
package/model_health.py
CHANGED
|
@@ -3,17 +3,19 @@ rerank). Never stores request content: the model, the role, the tool that was ru
|
|
|
3
3
|
text and the time.
|
|
4
4
|
|
|
5
5
|
Three consumers read it: the tool answer (a warning line when a call of that very tool failed), the setup screen
|
|
6
|
-
(rate table and
|
|
6
|
+
(rate table, failures by type and calls per purpose) and the tray (one toast per model per day when the rate passes the limit).
|
|
7
7
|
"""
|
|
8
8
|
import contextlib
|
|
9
9
|
import contextvars
|
|
10
10
|
import io
|
|
11
11
|
import os
|
|
12
|
+
import re
|
|
12
13
|
import sqlite3
|
|
13
14
|
import time
|
|
14
15
|
|
|
15
16
|
import config
|
|
16
17
|
import project_store
|
|
18
|
+
import usage_meter
|
|
17
19
|
|
|
18
20
|
WINDOW_S = 24 * 3600
|
|
19
21
|
ALERT_MIN_CALLS = 5
|
|
@@ -23,10 +25,17 @@ MUTE_S = 7 * 24 * 3600
|
|
|
23
25
|
KEEP_S = 7 * 24 * 3600
|
|
24
26
|
KEEP_FAILURES = 500
|
|
25
27
|
ERROR_CHARS = 300
|
|
28
|
+
SAMPLE_CHARS = 160
|
|
29
|
+
GROUP_ROWS = 20
|
|
30
|
+
PURPOSES = ("index_embedding", "query_embedding", "rerank", "embedding_probe", "scope")
|
|
26
31
|
_PATHS = ("/v1/chat/completions", "/v1/embeddings", "/v1/rerank")
|
|
27
32
|
_TOOL = contextvars.ContextVar("model_health_tool", default=None)
|
|
28
33
|
_COLLECTED = contextvars.ContextVar("model_health_collected", default=None)
|
|
29
34
|
_SCHEMA = set()
|
|
35
|
+
_STATUS = re.compile(r"HTTP(?: Error)? (\d{3})")
|
|
36
|
+
_TIMEOUT = re.compile(r"timed out|timeout|WinError 10060|Errno 110\b", re.I)
|
|
37
|
+
_CONNECTION = re.compile(r"URLError|ConnectionError|ConnectionRefused|ConnectionReset|refused|reset|getaddrinfo|"
|
|
38
|
+
r"WinError 1006[01]|WinError 10054|Name or service|unreachable", re.I)
|
|
30
39
|
|
|
31
40
|
|
|
32
41
|
@contextlib.contextmanager
|
|
@@ -56,6 +65,22 @@ def _tables(db):
|
|
|
56
65
|
_SCHEMA.add(project_store.DB_PATH)
|
|
57
66
|
|
|
58
67
|
|
|
68
|
+
def classify(error):
|
|
69
|
+
"""Failure type of a stored error text: '429', '503', '5xx' (any other 5xx), 'auth' (401/403), 'timeout',
|
|
70
|
+
'connection' or 'other'. An HTTP status in the text wins over words in the response body."""
|
|
71
|
+
status = _STATUS.search(error or "")
|
|
72
|
+
if status:
|
|
73
|
+
code = int(status.group(1))
|
|
74
|
+
if code in (429, 503):
|
|
75
|
+
return str(code)
|
|
76
|
+
if code in (401, 403):
|
|
77
|
+
return "auth"
|
|
78
|
+
return "5xx" if code >= 500 else "other"
|
|
79
|
+
if _TIMEOUT.search(error or ""):
|
|
80
|
+
return "timeout"
|
|
81
|
+
return "connection" if _CONNECTION.search(error or "") else "other"
|
|
82
|
+
|
|
83
|
+
|
|
59
84
|
def role_of(model, cfg=None):
|
|
60
85
|
"""Name of the config field that selects `model` ('other' when none does)."""
|
|
61
86
|
cfg = cfg if cfg is not None else config.load_config()
|
|
@@ -111,8 +136,24 @@ def warning_text(failures, setup_url):
|
|
|
111
136
|
f"{first['error']}. Pick another model: {setup_url}/setup#models")
|
|
112
137
|
|
|
113
138
|
|
|
139
|
+
def _groups(rows):
|
|
140
|
+
"""Failure rows (latest first) as one entry per (type, role): count, first and last time, the latest message cut to
|
|
141
|
+
SAMPLE_CHARS and the latest GROUP_ROWS raw rows. Largest group first."""
|
|
142
|
+
groups = {}
|
|
143
|
+
for row in rows:
|
|
144
|
+
group = groups.setdefault((classify(row["error"]), row["role"]), {
|
|
145
|
+
"type": classify(row["error"]), "role": row["role"], "count": 0, "first_at": row["at"], "last_at": row["at"],
|
|
146
|
+
"sample": row["error"][:SAMPLE_CHARS], "rows": []})
|
|
147
|
+
group["count"] += 1
|
|
148
|
+
group["first_at"] = row["at"]
|
|
149
|
+
if len(group["rows"]) < GROUP_ROWS:
|
|
150
|
+
group["rows"].append(row)
|
|
151
|
+
return sorted(groups.values(), key=lambda group: (-group["count"], -group["last_at"]))
|
|
152
|
+
|
|
153
|
+
|
|
114
154
|
def summary(now=None):
|
|
115
|
-
"""Per model and role over the last 24 h,
|
|
155
|
+
"""Per model and role over the last 24 h, every kept failure of the window grouped by type and role, and the calls
|
|
156
|
+
per purpose of the usage meter (its days are UTC, so that part covers the window's days, not its exact hours)."""
|
|
116
157
|
now = now or time.time()
|
|
117
158
|
with project_store._LOCK, _db() as db:
|
|
118
159
|
_tables(db)
|
|
@@ -121,7 +162,7 @@ def summary(now=None):
|
|
|
121
162
|
"SELECT model,role,SUM(calls) AS calls,SUM(errors) AS errors FROM model_calls WHERE hour>=? "
|
|
122
163
|
"GROUP BY model,role ORDER BY SUM(errors) DESC,SUM(calls) DESC", (int((now - WINDOW_S) // 3600),))]
|
|
123
164
|
failures = [dict(row) for row in db.execute(
|
|
124
|
-
"SELECT at,model,role,tool,path,error FROM model_failures WHERE at>=? ORDER BY at DESC
|
|
165
|
+
"SELECT at,model,role,tool,path,error FROM model_failures WHERE at>=? ORDER BY at DESC", (now - WINDOW_S,))]
|
|
125
166
|
for row in models:
|
|
126
167
|
last = db.execute("SELECT error FROM model_failures WHERE model=? AND role=? ORDER BY at DESC LIMIT 1",
|
|
127
168
|
(row["model"], row["role"])).fetchone()
|
|
@@ -129,7 +170,8 @@ def summary(now=None):
|
|
|
129
170
|
for row in models:
|
|
130
171
|
row["rate"] = round(row["errors"] / row["calls"], 3) if row["calls"] else 0.0
|
|
131
172
|
row["failing"] = row["calls"] >= ALERT_MIN_CALLS and row["rate"] >= ALERT_RATE
|
|
132
|
-
return {"window_hours": WINDOW_S // 3600, "models": models, "
|
|
173
|
+
return {"window_hours": WINDOW_S // 3600, "models": models, "groups": _groups(failures),
|
|
174
|
+
"calls_by_purpose": usage_meter.calls_by_purpose(now - WINDOW_S, PURPOSES)}
|
|
133
175
|
|
|
134
176
|
|
|
135
177
|
def alerts_due(now=None):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allansantos-dev/smart-tool",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.41",
|
|
4
4
|
"description": "Local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharper tools than their built-in search. Windows.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Allan Santos",
|
package/project_monitor.py
CHANGED
|
@@ -20,6 +20,8 @@ IDLE_TICK_S = 5
|
|
|
20
20
|
# A folder that changes all the time (a tool's state folder) is indexed at most this often by the monitor; the changes
|
|
21
21
|
# keep accumulating and one job covers them, and a search meanwhile reads the stale files straight from disk.
|
|
22
22
|
MIN_JOB_GAP_S = 60
|
|
23
|
+
# While the changes do not stop, each job lengthens the wait for the next one; a wait that passes unused resets it.
|
|
24
|
+
JOB_GAP_STEPS_S = (MIN_JOB_GAP_S, 120, 240, 480, 900)
|
|
23
25
|
FULL_CHECK_S = 86400
|
|
24
26
|
MAX_EVENT_PATHS = 1000
|
|
25
27
|
|
|
@@ -27,7 +29,8 @@ MAX_EVENT_PATHS = 1000
|
|
|
27
29
|
def relevant_path(path):
|
|
28
30
|
parts = path.replace("\\", "/").strip("/").split("/")
|
|
29
31
|
return bool(parts and all(part not in ("", ".", "..") and not part.startswith(".")
|
|
30
|
-
and not index_scope.always_excluded(part) for part in parts)
|
|
32
|
+
and not index_scope.always_excluded(part) for part in parts)
|
|
33
|
+
and not index_scope.is_transient(parts[-1]))
|
|
31
34
|
|
|
32
35
|
|
|
33
36
|
def snapshot(root, scope):
|
|
@@ -58,6 +61,7 @@ class ProjectMonitor:
|
|
|
58
61
|
self._git_checked = {}
|
|
59
62
|
self._pending = {}
|
|
60
63
|
self._next_job = {}
|
|
64
|
+
self._gap_level = {}
|
|
61
65
|
self._filters = {}
|
|
62
66
|
self._views = {}
|
|
63
67
|
self._lock = threading.RLock()
|
|
@@ -155,6 +159,17 @@ class ProjectMonitor:
|
|
|
155
159
|
project_store.update(key, watch_backend="windows_events", watch_error="")
|
|
156
160
|
watcher.start()
|
|
157
161
|
|
|
162
|
+
def _job_gap(self, key, now):
|
|
163
|
+
"""Wait before the next job of a project, called when a job is enqueued at `now`: one step longer than the last
|
|
164
|
+
one when the previous wait was used up at once (at most twice its length went by), the first step otherwise."""
|
|
165
|
+
level, at = self._gap_level.get(key, (0, None))
|
|
166
|
+
if at is None or now - at >= 2 * JOB_GAP_STEPS_S[level]:
|
|
167
|
+
level = 0
|
|
168
|
+
else:
|
|
169
|
+
level = min(level + 1, len(JOB_GAP_STEPS_S) - 1)
|
|
170
|
+
self._gap_level[key] = (level, now)
|
|
171
|
+
return JOB_GAP_STEPS_S[level]
|
|
172
|
+
|
|
158
173
|
def tick(self):
|
|
159
174
|
now = time.monotonic()
|
|
160
175
|
projects = project_store.all_projects()
|
|
@@ -223,7 +238,7 @@ class ProjectMonitor:
|
|
|
223
238
|
except Exception as exc:
|
|
224
239
|
project_store.update(key, status="error", last_error=str(exc)[:300], next_retry_at=time.time() + 60)
|
|
225
240
|
continue
|
|
226
|
-
self._next_job[key] = now +
|
|
241
|
+
self._next_job[key] = now + self._job_gap(key, now)
|
|
227
242
|
with self._lock:
|
|
228
243
|
# Um evento recebido durante o submit continua pendente para a próxima geração.
|
|
229
244
|
if self._pending.get(key) is pending and pending.get("revision", 0) == revision:
|
package/setup_ui.py
CHANGED
|
@@ -255,6 +255,10 @@ def handle_integration_get():
|
|
|
255
255
|
duplicate_mode, duplicate_error = config.duplicate_mode(), ""
|
|
256
256
|
except ValueError as exc:
|
|
257
257
|
duplicate_mode, duplicate_error = "", str(exc)
|
|
258
|
+
try:
|
|
259
|
+
dependency_mode, dependency_error = config.dependency_mode(), ""
|
|
260
|
+
except ValueError as exc:
|
|
261
|
+
dependency_mode, dependency_error = "", str(exc)
|
|
258
262
|
try:
|
|
259
263
|
proposals_mode, proposals_error = config.pattern_proposals(), ""
|
|
260
264
|
except ValueError as exc:
|
|
@@ -267,6 +271,8 @@ def handle_integration_get():
|
|
|
267
271
|
"hook_modes": list(config.HOOK_MODES), "doc_mode": doc_mode, "doc_mode_error": doc_error,
|
|
268
272
|
"doc_modes": list(config.DOC_MODES), "duplicate_mode": duplicate_mode,
|
|
269
273
|
"duplicate_mode_error": duplicate_error, "duplicate_modes": list(config.DUPLICATE_MODES),
|
|
274
|
+
"dependency_mode": dependency_mode, "dependency_mode_error": dependency_error,
|
|
275
|
+
"dependency_modes": list(config.DEPENDENCY_MODES),
|
|
270
276
|
"pattern_proposals": proposals_mode, "pattern_proposals_error": proposals_error,
|
|
271
277
|
"block_patterns": {"proposals": stored["proposals"], "accepted": stored["accepted"],
|
|
272
278
|
"rejected": len(stored["rejected"]), "error": patterns_error},
|
|
@@ -285,10 +291,11 @@ def handle_integration_save(body):
|
|
|
285
291
|
except (OSError, ValueError) as exc:
|
|
286
292
|
raise SetupError(str(exc))
|
|
287
293
|
return handle_integration_get()
|
|
288
|
-
if isinstance(body, dict) and body.get("action") in ("hook_mode", "doc_mode", "duplicate_mode", "pattern_proposals"):
|
|
294
|
+
if isinstance(body, dict) and body.get("action") in ("hook_mode", "doc_mode", "duplicate_mode", "dependency_mode", "pattern_proposals"):
|
|
289
295
|
action = body["action"]
|
|
290
296
|
allowed = {"hook_mode": config.HOOK_MODES, "doc_mode": config.DOC_MODES,
|
|
291
|
-
"duplicate_mode": config.DUPLICATE_MODES, "
|
|
297
|
+
"duplicate_mode": config.DUPLICATE_MODES, "dependency_mode": config.DEPENDENCY_MODES,
|
|
298
|
+
"pattern_proposals": config.PATTERN_PROPOSAL_MODES}[action]
|
|
292
299
|
if body.get("mode") not in allowed:
|
|
293
300
|
raise SetupError(f"Invalid mode: use {', '.join(allowed)}.")
|
|
294
301
|
try:
|
|
@@ -740,7 +747,7 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
740
747
|
|
|
741
748
|
<div class="card" id="health-card">
|
|
742
749
|
<div class="card-head"><h3>Model failures (last 24 h)</h3><span class="pill" id="health-pill" data-tone="busy">Checking</span></div>
|
|
743
|
-
<p class="lede">
|
|
750
|
+
<p class="lede">Failed calls to a gateway model, grouped by type (429, 503, timeout, ...) and role, with the latest lines of each group. A model that fails half of its calls (5 or more) also raises a Windows notification.</p>
|
|
744
751
|
<div id="health-table"></div>
|
|
745
752
|
</div>
|
|
746
753
|
|
|
@@ -1153,15 +1160,29 @@ for (const [id, action] of [["btn-upgrade-apply", "apply"], ["btn-upgrade-declin
|
|
|
1153
1160
|
}
|
|
1154
1161
|
});
|
|
1155
1162
|
}
|
|
1163
|
+
const FAILURE_TYPES = { "429": "429 Too Many Requests", "503": "503 Service Unavailable", "5xx": "Other 5xx", timeout: "Timeout",
|
|
1164
|
+
connection: "Connection", auth: "Authentication (401/403)", other: "Other" };
|
|
1165
|
+
const PURPOSE_LABELS = { index_embedding: "Indexing", query_embedding: "Query", rerank: "Rerank", embedding_probe: "Model check", scope: "Scope" };
|
|
1166
|
+
function healthHtml(data) {
|
|
1167
|
+
const when = (at) => esc(new Date(at * 1000).toLocaleString());
|
|
1168
|
+
const models = data.models.map((row) => `<tr><td>${esc(row.role)}</td><td>${esc(row.model)}</td><td>${row.errors} / ${row.calls}</td><td>${Math.round(row.rate * 100)}%</td></tr>`).join("");
|
|
1169
|
+
const purposes = data.calls_by_purpose.map((row) => `<tr><td>${esc(PURPOSE_LABELS[row.purpose] || row.purpose)}</td><td>${row.attempts}</td><td>${row.errors}</td></tr>`).join("");
|
|
1170
|
+
const groups = data.groups.map((group) => {
|
|
1171
|
+
const rows = group.rows.map((row) => `<tr><td>${when(row.at)}</td><td>${esc(row.model)}</td><td>${esc(row.tool || "-")}</td><td>${esc(row.error)}</td></tr>`).join("");
|
|
1172
|
+
return `<details class="more health-group" data-type="${esc(group.type)}"><summary>${esc(FAILURE_TYPES[group.type] || group.type)} · ${esc(group.role)} · ${plural(group.count, "failure")} · ${when(group.first_at)} to ${when(group.last_at)}</summary>`
|
|
1173
|
+
+ `<p class="mono">${esc(group.sample)}</p><table><tr><th>When</th><th>Model</th><th>Tool</th><th>Error</th></tr>${rows}</table>`
|
|
1174
|
+
+ (group.count > group.rows.length ? `<p>Showing the latest ${group.rows.length} of ${group.count}.</p>` : "") + "</details>";
|
|
1175
|
+
}).join("");
|
|
1176
|
+
return (models ? `<table><tr><th>Role</th><th>Model</th><th>Failed / calls</th><th>Rate</th></tr>${models}</table>` : "<p>No model call in the last 24 hours.</p>")
|
|
1177
|
+
+ (purposes ? `<h4>Calls by purpose</h4><table><tr><th>Purpose</th><th>Calls</th><th>Failed</th></tr>${purposes}</table>` : "")
|
|
1178
|
+
+ (groups ? `<h4>Failures by type</h4>${groups}` : "");
|
|
1179
|
+
}
|
|
1156
1180
|
async function loadHealth() {
|
|
1157
1181
|
try {
|
|
1158
1182
|
const data = await getJSON("/setup/models/health");
|
|
1159
1183
|
const failing = data.models.filter((row) => row.failing).length;
|
|
1160
1184
|
setPill("health-pill", failing ? "bad" : "ok", failing ? plural(failing, "model") + " failing" : "No problem");
|
|
1161
|
-
|
|
1162
|
-
const failures = data.failures.map((row) => `<tr><td>${esc(new Date(row.at * 1000).toLocaleString())}</td><td>${esc(row.role)}</td><td>${esc(row.model)}</td><td>${esc(row.tool || "-")}</td><td>${esc(row.error)}</td></tr>`).join("");
|
|
1163
|
-
$("health-table").innerHTML = (models ? `<table><tr><th>Role</th><th>Model</th><th>Failed / calls</th><th>Rate</th></tr>${models}</table>` : "<p>No model call in the last 24 hours.</p>")
|
|
1164
|
-
+ (failures ? `<table><tr><th>When</th><th>Role</th><th>Model</th><th>Tool</th><th>Error</th></tr>${failures}</table>` : "");
|
|
1185
|
+
$("health-table").innerHTML = healthHtml(data);
|
|
1165
1186
|
} catch (error) {
|
|
1166
1187
|
setPill("health-pill", "bad", "Unavailable");
|
|
1167
1188
|
$("health-table").textContent = "Could not read the failure log: " + error.message;
|
|
@@ -1270,6 +1291,10 @@ const RULES = [
|
|
|
1270
1291
|
intro: "Compares the functions an edit writes with the indexed code: identical bodies, and near-identical ones with only local names changed. It never blocks.",
|
|
1271
1292
|
options: [["warn", "Warn", "The edit runs; the agent is told when a function copies one already in the project.", true],
|
|
1272
1293
|
["off", "Off", "No duplicate checks."]] },
|
|
1294
|
+
{ key: "dependency_mode", title: "Vulnerable dependencies on edit", applies: "Applies on the next edit.",
|
|
1295
|
+
intro: "Looks up the dependencies an edit of a dependency manifest adds or changes in the OSV advisory database. It never blocks.",
|
|
1296
|
+
options: [["warn", "Warn", "The edit runs; the agent is told which added or changed dependencies have known advisories.", true],
|
|
1297
|
+
["off", "Off", "No dependency checks and no network call."]] },
|
|
1273
1298
|
{ key: "pattern_proposals", title: "Block patterns proposed by a model", applies: "Applies to the next review.",
|
|
1274
1299
|
intro: "Afterwards, never delaying a call, the scope model (else the router model) reviews searches the fixed rule let through and may propose block patterns. Nothing blocks until you accept it on the Redirects page.",
|
|
1275
1300
|
options: [["on", "On", "Review in the background and list proposals for you.", true],
|
package/smart_tool_daemon.py
CHANGED
|
@@ -676,69 +676,97 @@ def _embed_retry_delay(exc, attempt):
|
|
|
676
676
|
return min(8, 2 ** (attempt - 1)) + random.uniform(0, .25)
|
|
677
677
|
|
|
678
678
|
|
|
679
|
+
def _wait_to_retry(exc, attempt, deadline=None, cancel_check=None):
|
|
680
|
+
"""Sleeps the wait the gateway asked for (`Retry-After`, else exponential with jitter) before the next attempt;
|
|
681
|
+
raises TimeoutError when that wait does not fit in `deadline`."""
|
|
682
|
+
delay = _embed_retry_delay(exc, attempt)
|
|
683
|
+
if deadline is not None and delay >= _research_time_left(deadline):
|
|
684
|
+
raise TimeoutError("Not enough time left to honor the gateway backoff.")
|
|
685
|
+
if cancel_check:
|
|
686
|
+
until = time.monotonic() + delay
|
|
687
|
+
while time.monotonic() < until:
|
|
688
|
+
cancel_check()
|
|
689
|
+
time.sleep(min(.2, max(0, until - time.monotonic())))
|
|
690
|
+
else:
|
|
691
|
+
time.sleep(delay)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _embed_request(model, batch, token, deadline=None, on_retry=None, cancel_check=None):
|
|
695
|
+
"""Vectors of one embeddings request in input order, repeating a transient failure up to EMBED_MAX_ATTEMPTS times."""
|
|
696
|
+
for attempt in range(1, EMBED_MAX_ATTEMPTS + 1):
|
|
697
|
+
if cancel_check:
|
|
698
|
+
cancel_check()
|
|
699
|
+
timeout = (min(EMBED_REQUEST_TIMEOUT_S, _research_time_left(deadline))
|
|
700
|
+
if deadline is not None else EMBED_REQUEST_TIMEOUT_S)
|
|
701
|
+
try:
|
|
702
|
+
response = model_client.fetch(
|
|
703
|
+
"/v1/embeddings", token, method="POST", body={"model": model, "input": batch},
|
|
704
|
+
timeout=timeout,
|
|
705
|
+
)
|
|
706
|
+
except Exception as exc:
|
|
707
|
+
if attempt >= EMBED_MAX_ATTEMPTS or not _transient_embed_error(exc):
|
|
708
|
+
raise
|
|
709
|
+
if on_retry:
|
|
710
|
+
on_retry(attempt + 1, len(batch))
|
|
711
|
+
_wait_to_retry(exc, attempt, deadline, cancel_check)
|
|
712
|
+
continue
|
|
713
|
+
if cancel_check:
|
|
714
|
+
cancel_check()
|
|
715
|
+
rows = response.get("data", []) if isinstance(response, dict) else []
|
|
716
|
+
if not isinstance(rows, list) or len(rows) != len(batch):
|
|
717
|
+
raise RuntimeError("The gateway returned a different number of embeddings than the input.")
|
|
718
|
+
ordered = [None] * len(batch)
|
|
719
|
+
for row in rows:
|
|
720
|
+
if not isinstance(row, dict):
|
|
721
|
+
raise indexer.IndexVectorError("Invalid embedding row.")
|
|
722
|
+
idx = row.get("index", 0 if len(batch) == 1 else None)
|
|
723
|
+
if type(idx) is not int or not 0 <= idx < len(batch) or ordered[idx] is not None:
|
|
724
|
+
raise indexer.IndexVectorError("Embedding indexes missing, duplicated or outside the batch.")
|
|
725
|
+
ordered[idx] = row.get("embedding")
|
|
726
|
+
return ordered
|
|
727
|
+
|
|
728
|
+
|
|
679
729
|
def _embed(model, texts, token, deadline=None, on_retry=None, cancel_check=None):
|
|
730
|
+
"""One vector per text. A request the gateway refuses for its size (HTTP 400 or 413) is split in two halves, each
|
|
731
|
+
sent once; a half that is refused again raises."""
|
|
680
732
|
if local_embedder.is_local(model):
|
|
681
733
|
return local_embedder.embed(model, texts, deadline=deadline, cancel_check=cancel_check, on_retry=on_retry)
|
|
682
734
|
vectors = []
|
|
683
735
|
dimensions = None
|
|
684
736
|
for batch in _embed_batches(texts):
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
except Exception as exc:
|
|
697
|
-
if attempt >= max_attempts or not _transient_embed_error(exc):
|
|
698
|
-
raise
|
|
699
|
-
if on_retry:
|
|
700
|
-
on_retry(attempt + 1, len(batch))
|
|
701
|
-
delay = _embed_retry_delay(exc, attempt)
|
|
702
|
-
if deadline is not None and delay >= _research_time_left(deadline):
|
|
703
|
-
raise TimeoutError("Not enough time left to honor the gateway backoff.")
|
|
704
|
-
if cancel_check:
|
|
705
|
-
until = time.monotonic() + delay
|
|
706
|
-
while time.monotonic() < until:
|
|
707
|
-
cancel_check()
|
|
708
|
-
time.sleep(min(.2, max(0, until - time.monotonic())))
|
|
709
|
-
else:
|
|
710
|
-
time.sleep(delay)
|
|
711
|
-
continue
|
|
712
|
-
if cancel_check:
|
|
713
|
-
cancel_check()
|
|
714
|
-
rows = response.get("data", []) if isinstance(response, dict) else []
|
|
715
|
-
if not isinstance(rows, list) or len(rows) != len(batch):
|
|
716
|
-
raise RuntimeError("The gateway returned a different number of embeddings than the input.")
|
|
717
|
-
ordered = [None] * len(batch)
|
|
718
|
-
for row in rows:
|
|
719
|
-
if not isinstance(row, dict):
|
|
720
|
-
raise indexer.IndexVectorError("Invalid embedding row.")
|
|
721
|
-
idx = row.get("index", 0 if len(batch) == 1 else None)
|
|
722
|
-
if type(idx) is not int or not 0 <= idx < len(batch) or ordered[idx] is not None:
|
|
723
|
-
raise indexer.IndexVectorError("Embedding indexes missing, duplicated or outside the batch.")
|
|
724
|
-
vector = row.get("embedding")
|
|
725
|
-
dimensions = indexer.validate_vector(vector, dimensions)
|
|
726
|
-
ordered[idx] = vector
|
|
727
|
-
vectors.extend(ordered)
|
|
728
|
-
break
|
|
737
|
+
try:
|
|
738
|
+
ordered = _embed_request(model, batch, token, deadline, on_retry, cancel_check)
|
|
739
|
+
except urllib.error.HTTPError as exc:
|
|
740
|
+
if exc.code not in (400, 413) or len(batch) < 2:
|
|
741
|
+
raise
|
|
742
|
+
half = len(batch) // 2
|
|
743
|
+
ordered = (_embed_request(model, batch[:half], token, deadline, on_retry, cancel_check)
|
|
744
|
+
+ _embed_request(model, batch[half:], token, deadline, on_retry, cancel_check))
|
|
745
|
+
for vector in ordered:
|
|
746
|
+
dimensions = indexer.validate_vector(vector, dimensions)
|
|
747
|
+
vectors.extend(ordered)
|
|
729
748
|
return vectors
|
|
730
749
|
|
|
731
750
|
|
|
732
751
|
def _rerank(model, query, documents, top_n, token, deadline=None):
|
|
752
|
+
"""Rows `{index, relevance_score}` of the best `top_n` documents. Only an HTTP answer that is worth repeating
|
|
753
|
+
(429, 5xx) is asked again, after the wait of `_wait_to_retry`; a timeout fails at once so a search does not stall."""
|
|
733
754
|
if not documents:
|
|
734
755
|
return []
|
|
735
756
|
top_n = min(top_n, len(documents))
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
757
|
+
for attempt in range(1, EMBED_MAX_ATTEMPTS + 1):
|
|
758
|
+
timeout = min(15, _research_time_left(deadline)) if deadline is not None else 15
|
|
759
|
+
try:
|
|
760
|
+
response = model_client.fetch(
|
|
761
|
+
"/v1/rerank", token, method="POST",
|
|
762
|
+
body={"model": model, "query": query, "documents": documents, "top_n": top_n},
|
|
763
|
+
timeout=timeout,
|
|
764
|
+
)
|
|
765
|
+
break
|
|
766
|
+
except urllib.error.HTTPError as exc:
|
|
767
|
+
if attempt >= EMBED_MAX_ATTEMPTS or not _transient_embed_error(exc):
|
|
768
|
+
raise
|
|
769
|
+
_wait_to_retry(exc, attempt, deadline)
|
|
742
770
|
rows = response.get("results", []) if isinstance(response, dict) else []
|
|
743
771
|
if not isinstance(rows, list) or not rows:
|
|
744
772
|
raise ValueError("The reranker returned no valid results.")
|
|
@@ -1054,7 +1082,9 @@ def _prepare_project_index(arguments, job_id, cfg, token, query_vector=None, pre
|
|
|
1054
1082
|
return result
|
|
1055
1083
|
model = model or local_embedder.resolve(cfg.get("embedding_model"), indexer.info(root).get("model_id"))
|
|
1056
1084
|
lexical = model == indexer.LEXICAL_MODEL
|
|
1057
|
-
|
|
1085
|
+
current = indexer.info(root)
|
|
1086
|
+
known_dimensions = current.get("dimensions") if current.get("model_id") == model else None
|
|
1087
|
+
if not lexical and query_vector is None and not known_dimensions:
|
|
1058
1088
|
_update_job(job_id, phase="model_probe")
|
|
1059
1089
|
query_vector = _embed(model, ["Smart Tool: indexing dimension check."], token,
|
|
1060
1090
|
deadline=_job_deadline(job_id), cancel_check=checkpoint,
|
|
@@ -1062,7 +1092,6 @@ def _prepare_project_index(arguments, job_id, cfg, token, query_vector=None, pre
|
|
|
1062
1092
|
retry_attempt=attempt, batch_items=items))[0]
|
|
1063
1093
|
checkpoint()
|
|
1064
1094
|
_update_job(job_id, phase="indexing")
|
|
1065
|
-
current = indexer.info(root)
|
|
1066
1095
|
full_check = bool(arguments.get("full_check") or project.get("needs_full_check") or
|
|
1067
1096
|
time.time() - current.get("full_scan_at", 0) >= project_monitor.FULL_CHECK_S)
|
|
1068
1097
|
stats = indexer.reindex(
|
|
@@ -1072,7 +1101,7 @@ def _prepare_project_index(arguments, job_id, cfg, token, query_vector=None, pre
|
|
|
1072
1101
|
job_id, phase="indexing_retry", retry_attempt=attempt, batch_items=items)),
|
|
1073
1102
|
on_progress=lambda processed, total: _update_job(
|
|
1074
1103
|
job_id, phase="indexing", processed_files=processed, total_files=total),
|
|
1075
|
-
model_id=model, expected_dimensions=None if lexical else len(query_vector),
|
|
1104
|
+
model_id=model, expected_dimensions=None if lexical else known_dimensions or len(query_vector),
|
|
1076
1105
|
force_rebuild=bool(arguments.get("force_rebuild")), full_check=full_check,
|
|
1077
1106
|
changed_paths=set(arguments.get("changed_paths") or []) | set(project.get('dirty_paths', [])), cancel_check=checkpoint,
|
|
1078
1107
|
)
|
|
@@ -1110,6 +1139,33 @@ LEXICAL_WARNING = ("No embedding model: index and search are lexical only (exact
|
|
|
1110
1139
|
"Configure an embedding_model for semantic search.")
|
|
1111
1140
|
|
|
1112
1141
|
|
|
1142
|
+
def _rerank_blocks(model, groups, queries, found, vectors, token, deadline=None):
|
|
1143
|
+
"""`({group: rows}, {group: error})` of the blocks that have candidates and a query vector. One rerank request goes
|
|
1144
|
+
out per distinct query, not per block: the candidates of the blocks that share a query travel together and come back
|
|
1145
|
+
split by block (`index` is the position inside the block's candidates). A reranker scores each document against the
|
|
1146
|
+
query on its own, so a block gets the order and scores a request of its own would give."""
|
|
1147
|
+
ranked, failed = {}, {}
|
|
1148
|
+
by_query = {}
|
|
1149
|
+
for group in groups:
|
|
1150
|
+
if found[group] and vectors.get(queries[group]) is not None:
|
|
1151
|
+
by_query.setdefault(queries[group], []).append(group)
|
|
1152
|
+
for query, members in by_query.items():
|
|
1153
|
+
owners = [(group, position) for group in members for position in range(len(found[group]))]
|
|
1154
|
+
documents = [found[group][position][4] for group, position in owners]
|
|
1155
|
+
try:
|
|
1156
|
+
rows = _rerank(model, query, documents, len(documents), token, deadline=deadline)
|
|
1157
|
+
except Exception as exc:
|
|
1158
|
+
failed.update({group: _project_error(exc) for group in members})
|
|
1159
|
+
continue
|
|
1160
|
+
ranked.update({group: [] for group in members})
|
|
1161
|
+
for row in rows:
|
|
1162
|
+
group, position = owners[row["index"]]
|
|
1163
|
+
ranked[group].append({"index": position, "relevance_score": row["relevance_score"]})
|
|
1164
|
+
failed.update({group: "ValueError: The reranker returned no result for this block."
|
|
1165
|
+
for group in members if not ranked[group]})
|
|
1166
|
+
return ranked, failed
|
|
1167
|
+
|
|
1168
|
+
|
|
1113
1169
|
def _perform_smart_search(arguments, job_id):
|
|
1114
1170
|
return contextvars.copy_context().run(_perform_smart_search_isolated, arguments, job_id)
|
|
1115
1171
|
|
|
@@ -1220,22 +1276,22 @@ def _perform_smart_search_isolated(arguments, job_id):
|
|
|
1220
1276
|
include=lambda path, group=group: index_profile.kind(path, profile) == group)
|
|
1221
1277
|
for group in groups}
|
|
1222
1278
|
|
|
1279
|
+
reranked, rerank_failed = {}, {}
|
|
1280
|
+
if cfg.get("rerank_model"):
|
|
1281
|
+
_update_job(job_id, phase="reranking")
|
|
1282
|
+
reranked, rerank_failed = _rerank_blocks(cfg["rerank_model"], groups, queries, found, vectors, token,
|
|
1283
|
+
deadline=_job_deadline(job_id))
|
|
1284
|
+
|
|
1223
1285
|
def search_block(group):
|
|
1224
1286
|
top_k = limits[group]
|
|
1225
1287
|
query, vector, candidates = queries[group], vectors.get(queries[group]), found[group]
|
|
1226
1288
|
ordered = [(score, "hybrid" if vector is not None else "lexical", row) for score, *row in candidates[:top_k]]
|
|
1227
1289
|
problem = None
|
|
1228
|
-
if
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
if not reranked or any(type(item.get("index")) is not int or not 0 <= item["index"] < len(candidates)
|
|
1234
|
-
for item in reranked):
|
|
1235
|
-
raise ValueError("Invalid index in the reranker response.")
|
|
1236
|
-
ordered = [(float(item["relevance_score"]), "rerank", candidates[item["index"]][1:]) for item in reranked]
|
|
1237
|
-
except Exception as exc:
|
|
1238
|
-
problem = f"Rerank unavailable for {group}; hybrid lexical/vector order kept. " + _project_error(exc)
|
|
1290
|
+
if group in rerank_failed:
|
|
1291
|
+
problem = f"Rerank unavailable for {group}; hybrid lexical/vector order kept. " + rerank_failed[group]
|
|
1292
|
+
elif group in reranked:
|
|
1293
|
+
ordered = [(float(item["relevance_score"]), "rerank", candidates[item["index"]][1:])
|
|
1294
|
+
for item in reranked[group][:top_k]]
|
|
1239
1295
|
exact = indexer.overlay_exact_keys(overlay, query)
|
|
1240
1296
|
if exact:
|
|
1241
1297
|
pinned = [(c[0], "overlay", c[1:]) for c in candidates if tuple(c[1:4]) in exact][:2]
|
package/usage_meter.py
CHANGED
|
@@ -97,6 +97,19 @@ def _record_job(job_id,payload):
|
|
|
97
97
|
db.execute('DELETE FROM usage_index_jobs WHERE at<?',(time.time()-366*86400,))
|
|
98
98
|
|
|
99
99
|
|
|
100
|
+
def calls_by_purpose(since,purposes):
|
|
101
|
+
"""Attempts and errors of every project per purpose in `purposes`, from the UTC day of the epoch time `since` on
|
|
102
|
+
(the table keeps days, not hours). Purposes with no call are left out."""
|
|
103
|
+
day=datetime.datetime.fromtimestamp(since,datetime.timezone.utc).date().isoformat()
|
|
104
|
+
marks=','.join('?' for _ in purposes)
|
|
105
|
+
with project_store._LOCK,project_store._db() as db:
|
|
106
|
+
_tables(db)
|
|
107
|
+
rows=db.execute('SELECT purpose,SUM(attempts),SUM(errors) FROM usage_daily WHERE day>=? AND purpose IN ('+marks+') GROUP BY purpose',
|
|
108
|
+
(day,*purposes)).fetchall()
|
|
109
|
+
found={purpose:{'purpose':purpose,'attempts':attempts,'errors':errors} for purpose,attempts,errors in rows}
|
|
110
|
+
return [found[purpose] for purpose in purposes if purpose in found]
|
|
111
|
+
|
|
112
|
+
|
|
100
113
|
def summary(project_id,days=30,view_id=None):
|
|
101
114
|
if type(days) is not int or not 1<=days<=366:raise ValueError('Choose a period from 1 to 366 days.')
|
|
102
115
|
with project_store._LOCK,project_store._db() as db:
|
package/version.py
CHANGED
package/web_fetch.py
CHANGED
|
@@ -38,8 +38,7 @@ SELECT_BUDGET_CHARS = 15_000
|
|
|
38
38
|
CHUNK_CHARS = 2_000
|
|
39
39
|
PAGE_FORMAT = 2
|
|
40
40
|
MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024
|
|
41
|
-
|
|
42
|
-
PDF_TIMEOUT_S = 30
|
|
41
|
+
PDF_INTERACTIVE_TIMEOUT_S = 30 # Override of document_text.PDF_TIMEOUT_S: a person waits for this answer, the indexer does not.
|
|
43
42
|
MAX_REDIRECTS = 5
|
|
44
43
|
HTTP_TIMEOUT_S = 20
|
|
45
44
|
READ_CHUNK = 64 * 1024
|
|
@@ -212,12 +211,13 @@ def thin(text):
|
|
|
212
211
|
|
|
213
212
|
|
|
214
213
|
def pdf_text(body, url, deadline):
|
|
215
|
-
"""Text of a PDF (pypdf, pure Python), page by page
|
|
216
|
-
sends it here, so a PDF used to be a dead end (9 times in 10 days of transcripts).
|
|
217
|
-
parse for minutes, so it runs in a subprocess killed at the request's deadline (at most
|
|
218
|
-
|
|
214
|
+
"""Text of a PDF (pypdf, pure Python), page by page, with the page and character caps of document_text. The native
|
|
215
|
+
WebFetch reads PDFs and the hook sends it here, so a PDF used to be a dead end (9 times in 10 days of transcripts).
|
|
216
|
+
A hostile PDF can make pypdf parse for minutes, so it runs in a subprocess killed at the request's deadline (at most
|
|
217
|
+
PDF_INTERACTIVE_TIMEOUT_S, passed to document_text.run_pdf instead of the indexer's document_text.PDF_TIMEOUT_S)."""
|
|
218
|
+
timeout = min(PDF_INTERACTIVE_TIMEOUT_S, _left(deadline))
|
|
219
219
|
try:
|
|
220
|
-
return document_text.run_pdf(body, timeout, PDF_MAX_PAGES,
|
|
220
|
+
return document_text.run_pdf(body, timeout, document_text.PDF_MAX_PAGES, document_text.PDF_MAX_CHARS)
|
|
221
221
|
except document_text.PdfTimeout:
|
|
222
222
|
raise FetchError(f"PDF not read in {timeout:.0f}s ({url}).") from None
|
|
223
223
|
except document_text.PdfUnreadable as exc:
|