@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/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) and models over the failure limit
205
- (once a day each). Fetching marks them announced, so each toast is shown once per reminder period."""
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(), "check_error": upgrade["check_error"]}
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, "pattern_proposals": config.PATTERN_PROPOSAL_MODES}[action]
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">Every failed call to a gateway model, by role and tool. A model that fails half of its calls (5 or more) also raises a Windows notification.</p>
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
- 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("");
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],
@@ -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. Does not read localhost, internal networks or PDFs."
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
- max_attempts = EMBED_MAX_ATTEMPTS if deadline is None or on_retry is not None else 1
686
- for attempt in range(1, max_attempts + 1):
687
- if cancel_check:
688
- cancel_check()
689
- timeout = (min(EMBED_REQUEST_TIMEOUT_S, _research_time_left(deadline))
690
- if deadline is not None else EMBED_REQUEST_TIMEOUT_S)
691
- try:
692
- response = model_client.fetch(
693
- "/v1/embeddings", token, method="POST", body={"model": model, "input": batch},
694
- timeout=timeout,
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
- timeout = min(15, _research_time_left(deadline)) if deadline is not None else 15
737
- response = model_client.fetch(
738
- "/v1/rerank", token, method="POST",
739
- body={"model": model, "query": query, "documents": documents, "top_n": top_n},
740
- timeout=timeout,
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
- if not lexical and query_vector is None:
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 cfg.get("rerank_model") and candidates and vector is not None:
1229
- _update_job(job_id, phase="reranking")
1230
- try:
1231
- reranked = _rerank(cfg["rerank_model"], query, [c[4] for c in candidates], top_k, token,
1232
- deadline=_job_deadline(job_id))
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 and each model over the failure limit."""
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
@@ -1,7 +1,7 @@
1
1
  """Single source of the Smart Tool version and of the User-Agent sent to public APIs."""
2
2
  import re
3
3
 
4
- VERSION = "0.9.39"
4
+ VERSION = "0.9.41"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7
 
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
- PDF_MAX_PAGES = 300
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 up to PDF_MAX_PAGES. The native WebFetch reads PDFs and the hook
235
- sends it here, so a PDF used to be a dead end (9 times in 10 days of transcripts). A hostile PDF can make pypdf
236
- parse for minutes, so it runs in a subprocess killed at the request's deadline (at most PDF_TIMEOUT_S)."""
237
- timeout = min(PDF_TIMEOUT_S, _left(deadline))
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
- done = subprocess.run([sys.executable, "-I", "-c", PDF_SCRIPT, str(PDF_MAX_PAGES), str(PAGE_STORE_MAX_CHARS)],
240
- input=body, capture_output=True, timeout=timeout,
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
- if done.returncode:
245
- detail = (done.stderr.decode("utf-8", "replace").strip().splitlines() or ["no output"])[-1]
246
- raise FetchError(f"PDF could not be read ({url}): {detail}"[:300])
247
- result = json.loads(done.stdout)
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):