@allansantos-dev/smart-tool 0.9.39 → 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 +46 -0
- package/README.md +12 -5
- package/config.py +11 -0
- package/dependency_notice.py +280 -0
- package/document_text.py +114 -3
- package/freshness.py +64 -19
- package/hook_decision.py +41 -11
- package/index_profile.py +1 -1
- package/index_scope.py +9 -1
- package/indexer.py +10 -8
- 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/scout.py +16 -3
- package/setup_audit.py +46 -4
- package/setup_ui.py +37 -10
- package/smart_tool_daemon.py +130 -68
- package/tray.py +7 -1
- package/usage_meter.py +13 -0
- package/version.py +1 -1
- package/web_fetch.py +13 -41
package/setup_ui.py
CHANGED
|
@@ -201,11 +201,13 @@ def handle_health_get():
|
|
|
201
201
|
|
|
202
202
|
|
|
203
203
|
def handle_notices():
|
|
204
|
-
"""What the tray should show now: the model offer (once a day until answered)
|
|
205
|
-
(once a day each)
|
|
204
|
+
"""What the tray should show now: the model offer (once a day until answered), models over the failure limit
|
|
205
|
+
(once a day each) and the new errors or warnings of the scheduled setup audit (once per finding). Fetching marks
|
|
206
|
+
them announced, so each toast is shown once per reminder period."""
|
|
206
207
|
upgrade = _checked_upgrade()
|
|
207
208
|
offer = model_upgrade.due() if upgrade["state"] == "pending" else None
|
|
208
|
-
return {"upgrade": offer, "failing": model_health.alerts_due(), "
|
|
209
|
+
return {"upgrade": offer, "failing": model_health.alerts_due(), "audit": setup_audit.notices_due(),
|
|
210
|
+
"check_error": upgrade["check_error"]}
|
|
209
211
|
|
|
210
212
|
|
|
211
213
|
def handle_local_models_get():
|
|
@@ -253,6 +255,10 @@ def handle_integration_get():
|
|
|
253
255
|
duplicate_mode, duplicate_error = config.duplicate_mode(), ""
|
|
254
256
|
except ValueError as exc:
|
|
255
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)
|
|
256
262
|
try:
|
|
257
263
|
proposals_mode, proposals_error = config.pattern_proposals(), ""
|
|
258
264
|
except ValueError as exc:
|
|
@@ -265,6 +271,8 @@ def handle_integration_get():
|
|
|
265
271
|
"hook_modes": list(config.HOOK_MODES), "doc_mode": doc_mode, "doc_mode_error": doc_error,
|
|
266
272
|
"doc_modes": list(config.DOC_MODES), "duplicate_mode": duplicate_mode,
|
|
267
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),
|
|
268
276
|
"pattern_proposals": proposals_mode, "pattern_proposals_error": proposals_error,
|
|
269
277
|
"block_patterns": {"proposals": stored["proposals"], "accepted": stored["accepted"],
|
|
270
278
|
"rejected": len(stored["rejected"]), "error": patterns_error},
|
|
@@ -283,10 +291,11 @@ def handle_integration_save(body):
|
|
|
283
291
|
except (OSError, ValueError) as exc:
|
|
284
292
|
raise SetupError(str(exc))
|
|
285
293
|
return handle_integration_get()
|
|
286
|
-
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"):
|
|
287
295
|
action = body["action"]
|
|
288
296
|
allowed = {"hook_mode": config.HOOK_MODES, "doc_mode": config.DOC_MODES,
|
|
289
|
-
"duplicate_mode": config.DUPLICATE_MODES, "
|
|
297
|
+
"duplicate_mode": config.DUPLICATE_MODES, "dependency_mode": config.DEPENDENCY_MODES,
|
|
298
|
+
"pattern_proposals": config.PATTERN_PROPOSAL_MODES}[action]
|
|
290
299
|
if body.get("mode") not in allowed:
|
|
291
300
|
raise SetupError(f"Invalid mode: use {', '.join(allowed)}.")
|
|
292
301
|
try:
|
|
@@ -738,7 +747,7 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
738
747
|
|
|
739
748
|
<div class="card" id="health-card">
|
|
740
749
|
<div class="card-head"><h3>Model failures (last 24 h)</h3><span class="pill" id="health-pill" data-tone="busy">Checking</span></div>
|
|
741
|
-
<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>
|
|
742
751
|
<div id="health-table"></div>
|
|
743
752
|
</div>
|
|
744
753
|
|
|
@@ -1151,15 +1160,29 @@ for (const [id, action] of [["btn-upgrade-apply", "apply"], ["btn-upgrade-declin
|
|
|
1151
1160
|
}
|
|
1152
1161
|
});
|
|
1153
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
|
+
}
|
|
1154
1180
|
async function loadHealth() {
|
|
1155
1181
|
try {
|
|
1156
1182
|
const data = await getJSON("/setup/models/health");
|
|
1157
1183
|
const failing = data.models.filter((row) => row.failing).length;
|
|
1158
1184
|
setPill("health-pill", failing ? "bad" : "ok", failing ? plural(failing, "model") + " failing" : "No problem");
|
|
1159
|
-
|
|
1160
|
-
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("");
|
|
1161
|
-
$("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>")
|
|
1162
|
-
+ (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);
|
|
1163
1186
|
} catch (error) {
|
|
1164
1187
|
setPill("health-pill", "bad", "Unavailable");
|
|
1165
1188
|
$("health-table").textContent = "Could not read the failure log: " + error.message;
|
|
@@ -1268,6 +1291,10 @@ const RULES = [
|
|
|
1268
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.",
|
|
1269
1292
|
options: [["warn", "Warn", "The edit runs; the agent is told when a function copies one already in the project.", true],
|
|
1270
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."]] },
|
|
1271
1298
|
{ key: "pattern_proposals", title: "Block patterns proposed by a model", applies: "Applies to the next review.",
|
|
1272
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.",
|
|
1273
1300
|
options: [["on", "On", "Review in the background and list proposals for you.", true],
|
package/smart_tool_daemon.py
CHANGED
|
@@ -529,7 +529,7 @@ TOOLS = [
|
|
|
529
529
|
"description": (
|
|
530
530
|
"Reads a known URL and returns only the answer to the prompt, written by the gateway's mini model - use instead of "
|
|
531
531
|
"the native WebFetch. The page is cached for 24 h: another prompt on the same URL does not download it again. "
|
|
532
|
-
"JavaScript-built pages are rendered in the browser.
|
|
532
|
+
"JavaScript-built pages are rendered in the browser. Reads PDFs as text, page by page; does not read localhost or internal networks."
|
|
533
533
|
),
|
|
534
534
|
"inputSchema": {
|
|
535
535
|
"type": "object",
|
|
@@ -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]
|
|
@@ -1706,6 +1762,11 @@ def _handle_smart_scout(arguments):
|
|
|
1706
1762
|
others = list(dict.fromkeys(f"{row['path']}:{row['lines']}" for row in rows
|
|
1707
1763
|
if row["path"] not in shown))[:SCOUT_MAX_POINTERS]
|
|
1708
1764
|
source = _SCOUT_SOURCES[found["source"]]
|
|
1765
|
+
notes = []
|
|
1766
|
+
if scout.is_intent_question(question):
|
|
1767
|
+
dossier_answer = dossier.query(root, query, max_hits=scout.DECISION_NOTES_MAX)
|
|
1768
|
+
notes = dossier_answer["hits"]
|
|
1769
|
+
warnings += [f"decision notes: {reason}" for reason in dossier_answer["reasons"]]
|
|
1709
1770
|
text = None
|
|
1710
1771
|
if model and items:
|
|
1711
1772
|
calls += 1
|
|
@@ -1716,7 +1777,8 @@ def _handle_smart_scout(arguments):
|
|
|
1716
1777
|
found_answer, answer, citations = scout.valid_answer(raw, items)
|
|
1717
1778
|
note = f"{model} ({calls} call{'s' if calls > 1 else ''})"
|
|
1718
1779
|
if citations:
|
|
1719
|
-
text = scout.render(question, source, items, citations, answer, found_answer, note, warnings, others
|
|
1780
|
+
text = scout.render(question, source, items, citations, answer, found_answer, note, warnings, others,
|
|
1781
|
+
notes)
|
|
1720
1782
|
else:
|
|
1721
1783
|
shown_items = scout.evidence(root, rows, terms, max_files,
|
|
1722
1784
|
scout.LEXICAL_WINDOW_LINES)[:scout.UNCITED_WINDOWS]
|
|
@@ -1724,14 +1786,14 @@ def _handle_smart_scout(arguments):
|
|
|
1724
1786
|
if item["path"] not in {kept["path"] for kept in shown_items}]
|
|
1725
1787
|
text = scout.render(question, source, shown_items, None, answer, found_answer, note,
|
|
1726
1788
|
warnings + ["the scout_model cited no lines; the best keyword windows are shown "
|
|
1727
|
-
"instead."], rest + others)
|
|
1789
|
+
"instead."], rest + others, notes)
|
|
1728
1790
|
except Exception as exc:
|
|
1729
1791
|
warnings.append(f"scout_model {model} failed writing the answer ({_project_error(exc)}): literal lines "
|
|
1730
1792
|
"only, no answer.")
|
|
1731
1793
|
failure = "answer"
|
|
1732
1794
|
items = scout.evidence(root, rows, terms, max_files, scout.LEXICAL_WINDOW_LINES)
|
|
1733
1795
|
if text is None:
|
|
1734
|
-
text = scout.render(question, source, items, warnings=warnings, others=others)
|
|
1796
|
+
text = scout.render(question, source, items, warnings=warnings, others=others, notes=notes)
|
|
1735
1797
|
_metric("scout", source=found["source"], model=bool(model) and failure is None, model_calls=calls, failure=failure,
|
|
1736
1798
|
files=len({item["path"] for item in items}), chars=len(text), elapsed_s=round(time.monotonic() - started, 2))
|
|
1737
1799
|
return _sanitize_text(text)
|
package/tray.py
CHANGED
|
@@ -22,6 +22,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
22
22
|
import autostart
|
|
23
23
|
import config
|
|
24
24
|
import daemon_launcher
|
|
25
|
+
import setup_audit
|
|
25
26
|
import toast_notice
|
|
26
27
|
import update_check
|
|
27
28
|
import version
|
|
@@ -114,7 +115,8 @@ def _poll_loop(icon):
|
|
|
114
115
|
|
|
115
116
|
|
|
116
117
|
def _show_notices(icon, notices):
|
|
117
|
-
"""Shows a toast for each pending notice: the new-default-models offer
|
|
118
|
+
"""Shows a toast for each pending notice: the new-default-models offer, each model over the failure limit and each
|
|
119
|
+
project whose scheduled setup audit found new errors or warnings."""
|
|
118
120
|
offer = notices.get("upgrade")
|
|
119
121
|
if offer:
|
|
120
122
|
lines = [f"{role}: {change['current']} -> {change['new']}" for role, change in offer["changes"].items()]
|
|
@@ -129,6 +131,10 @@ def _show_notices(icon, notices):
|
|
|
129
131
|
f"{row['model']} is failing", [f"{row['errors']} of {row['calls']} calls failed in 24 h as {row['role']}.", last[:160]],
|
|
130
132
|
[("Switch model", toast_notice.action_uri("open-setup", section="models")),
|
|
131
133
|
("Mute 7 days", toast_notice.action_uri("mute-model", model=row["model"]))], tag="model-failing:" + row["model"])
|
|
134
|
+
for row in notices.get("audit") or []:
|
|
135
|
+
title, lines = setup_audit.toast_text(row)
|
|
136
|
+
toast_notice.show(title, lines, [("Open the audit", toast_notice.action_uri("open-setup", section="audit"))],
|
|
137
|
+
tag="setup-audit:" + row["project"])
|
|
132
138
|
|
|
133
139
|
|
|
134
140
|
def _notice_loop(icon):
|
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
|
@@ -20,14 +20,13 @@ import ipaddress
|
|
|
20
20
|
import json
|
|
21
21
|
import socket
|
|
22
22
|
import ssl
|
|
23
|
-
import subprocess
|
|
24
|
-
import sys
|
|
25
23
|
import threading
|
|
26
24
|
import time
|
|
27
25
|
import urllib.parse
|
|
28
26
|
|
|
29
27
|
import trafilatura
|
|
30
28
|
|
|
29
|
+
import document_text
|
|
31
30
|
import model_client
|
|
32
31
|
import research_cache
|
|
33
32
|
|
|
@@ -39,8 +38,7 @@ SELECT_BUDGET_CHARS = 15_000
|
|
|
39
38
|
CHUNK_CHARS = 2_000
|
|
40
39
|
PAGE_FORMAT = 2
|
|
41
40
|
MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024
|
|
42
|
-
|
|
43
|
-
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.
|
|
44
42
|
MAX_REDIRECTS = 5
|
|
45
43
|
HTTP_TIMEOUT_S = 20
|
|
46
44
|
READ_CHUNK = 64 * 1024
|
|
@@ -212,46 +210,20 @@ def thin(text):
|
|
|
212
210
|
return len(stripped) < PLACEHOLDER_SCAN_CHARS and any(p in stripped[:600].lower() for p in PLACEHOLDERS)
|
|
213
211
|
|
|
214
212
|
|
|
215
|
-
PDF_SCRIPT = """
|
|
216
|
-
import io, json, sys
|
|
217
|
-
import pypdf
|
|
218
|
-
reader = pypdf.PdfReader(io.BytesIO(sys.stdin.buffer.read()))
|
|
219
|
-
if reader.is_encrypted and reader.decrypt("") == pypdf.PasswordType.NOT_DECRYPTED:
|
|
220
|
-
print(json.dumps({"encrypted": True}))
|
|
221
|
-
sys.exit()
|
|
222
|
-
limit, budget = int(sys.argv[1]), int(sys.argv[2])
|
|
223
|
-
pages, size = [], 0
|
|
224
|
-
for page in reader.pages[:limit]:
|
|
225
|
-
pages.append((page.extract_text() or "").strip()[:budget - size])
|
|
226
|
-
size += len(pages[-1])
|
|
227
|
-
if size >= budget:
|
|
228
|
-
break
|
|
229
|
-
print(json.dumps({"pages": pages, "count": len(reader.pages)}))
|
|
230
|
-
"""
|
|
231
|
-
|
|
232
|
-
|
|
233
213
|
def pdf_text(body, url, deadline):
|
|
234
|
-
"""Text of a PDF (pypdf, pure Python), page by page
|
|
235
|
-
sends it here, so a PDF used to be a dead end (9 times in 10 days of transcripts).
|
|
236
|
-
parse for minutes, so it runs in a subprocess killed at the request's deadline (at most
|
|
237
|
-
|
|
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))
|
|
238
219
|
try:
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
242
|
-
except subprocess.TimeoutExpired:
|
|
220
|
+
return document_text.run_pdf(body, timeout, document_text.PDF_MAX_PAGES, document_text.PDF_MAX_CHARS)
|
|
221
|
+
except document_text.PdfTimeout:
|
|
243
222
|
raise FetchError(f"PDF not read in {timeout:.0f}s ({url}).") from None
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if result.get("encrypted"):
|
|
249
|
-
raise FetchError(f"PDF protected by a password is not read by web_fetch ({url}).")
|
|
250
|
-
pages = result["pages"]
|
|
251
|
-
text = "\n\n".join(f"[page {number}]\n{page}" for number, page in enumerate(pages, 1) if page)
|
|
252
|
-
if result["count"] > len(pages):
|
|
253
|
-
text += f"\n\n[pages {len(pages) + 1}-{result['count']} not read]"
|
|
254
|
-
return text
|
|
223
|
+
except document_text.PdfUnreadable as exc:
|
|
224
|
+
raise FetchError(f"PDF could not be read ({url}): {exc.detail}"[:300]) from None
|
|
225
|
+
except document_text.PdfEncrypted:
|
|
226
|
+
raise FetchError(f"PDF protected by a password is not read by web_fetch ({url}).") from None
|
|
255
227
|
|
|
256
228
|
|
|
257
229
|
def read_page(url, deadline, render):
|