@allansantos-dev/smart-tool 0.9.40 → 0.9.42
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 +43 -0
- package/README.md +11 -7
- package/client_hooks.py +94 -31
- package/config.py +1 -1
- package/dependency_notice.py +136 -32
- package/document_text.py +274 -47
- package/freshness.py +16 -10
- package/hook_decision.py +9 -7
- package/hooks/session_audit.py +1 -1
- package/index_profile.py +1 -1
- 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/scout.py +1 -1
- package/setup_ui.py +55 -22
- 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/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.42",
|
|
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/scout.py
CHANGED
|
@@ -102,7 +102,7 @@ def is_intent_question(question):
|
|
|
102
102
|
|
|
103
103
|
def disk_lines(root, path, start, end):
|
|
104
104
|
"""`[(line_number, text)]` of lines `start..end` of the file as it is on disk now, or None when the file cannot be
|
|
105
|
-
read by the index rules (secret, dotfile, too large, binary, gone) or holds no line text (docx, pdf)."""
|
|
105
|
+
read by the index rules (secret, dotfile, too large, binary, gone) or holds no line text (docx, pdf, pptx, xlsx)."""
|
|
106
106
|
if document_text.location_kind(path) != "source_line":
|
|
107
107
|
return None
|
|
108
108
|
data, encoding = indexer.read_indexable(root, path)
|
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:
|
|
@@ -318,21 +325,25 @@ def handle_integration_save(body):
|
|
|
318
325
|
def handle_audit(body):
|
|
319
326
|
"""Setup audit panel: `index` lists the registered projects with their last audit summary; `report` returns the
|
|
320
327
|
last stored report of one of them; `run` audits it now (MCP servers contacted unless probe is false). Only
|
|
321
|
-
registered project folders are accepted. The opt-in session-start report in Claude Code
|
|
328
|
+
registered project folders are accepted. The opt-in session-start report in Claude Code or Codex (`client`, default
|
|
329
|
+
claude; Codex runs it only after /hooks approval, state `waiting_approval`): `hook_state`,
|
|
322
330
|
`hook_preview` (the exact entry and a token), `hook_install` (with that token, after a backup) and `hook_remove`
|
|
323
331
|
(only Smart Tool's entry, after a backup)."""
|
|
324
332
|
if not isinstance(body, dict):
|
|
325
333
|
raise SetupError("Invalid request.")
|
|
326
334
|
roots = sorted({p["root"] for p in project_store.all_projects() if p.get("root")}, key=str.casefold)
|
|
327
335
|
try:
|
|
336
|
+
client = body.get("client", "claude")
|
|
328
337
|
if body.get("action") == "hook_state":
|
|
329
|
-
|
|
338
|
+
state = client_hooks.audit_state(client)
|
|
339
|
+
detail = {"detail": "installed, waiting for your approval in /hooks"} if state == client_hooks.AUDIT_WAITING else {}
|
|
340
|
+
return {"state": state, "file": client_hooks.CLIENTS[client]["files"][0], **detail}
|
|
330
341
|
if body.get("action") == "hook_preview":
|
|
331
|
-
return client_hooks.audit_preview()
|
|
342
|
+
return client_hooks.audit_preview(client)
|
|
332
343
|
if body.get("action") == "hook_install":
|
|
333
|
-
return client_hooks.audit_install(body.get("token"), True)
|
|
344
|
+
return client_hooks.audit_install(body.get("token"), True, client)
|
|
334
345
|
if body.get("action") == "hook_remove":
|
|
335
|
-
return client_hooks.audit_remove()
|
|
346
|
+
return client_hooks.audit_remove(client)
|
|
336
347
|
if body.get("action") == "index":
|
|
337
348
|
stored = setup_audit.index()
|
|
338
349
|
found = {root: stored[setup_audit.index_key(root)] for root in roots if setup_audit.index_key(root) in stored}
|
|
@@ -740,7 +751,7 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
740
751
|
|
|
741
752
|
<div class="card" id="health-card">
|
|
742
753
|
<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">
|
|
754
|
+
<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
755
|
<div id="health-table"></div>
|
|
745
756
|
</div>
|
|
746
757
|
|
|
@@ -802,8 +813,9 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
802
813
|
<header class="page-head"><h2 id="h-audit" tabindex="-1">Setup audit</h2>
|
|
803
814
|
<p>What Claude Code and Codex load for a project, and where they collide: hooks deciding on the same tool at once, hooks or MCP servers that do not work, tools offered twice, skills and instructions one agent never sees. It runs on its own for the registered projects and never changes anything.</p></header>
|
|
804
815
|
<div class="card">
|
|
805
|
-
<div class="card-head"><h3>Report at session start
|
|
806
|
-
<
|
|
816
|
+
<div class="card-head"><h3>Report at session start</h3><span class="pill" id="audit-hook-pill" data-tone="busy">Checking</span></div>
|
|
817
|
+
<div class="field"><label for="sel-audit-hook-client">Client</label><select id="sel-audit-hook-client"><option value="claude">Claude Code</option><option value="codex">Codex</option></select></div>
|
|
818
|
+
<p class="lede">Off by default. When on, every new session starts with one short line about the stored audit of its folder (errors and warnings only, nothing runs). It adds a SessionStart entry to <span class="mono" id="audit-hook-file"></span>, written after a backup, and turning it off removes only that entry. Codex runs a new hook only after you approve it in /hooks. Without it, the agent gets the same line once, on its first search.</p>
|
|
807
819
|
<div class="actions"><button type="button" class="primary" id="btn-audit-hook-on" hidden>Turn on</button><button type="button" id="btn-audit-hook-off" hidden>Turn off</button></div>
|
|
808
820
|
<div id="audit-hook-confirm" hidden>
|
|
809
821
|
<p class="hint" id="audit-hook-where"></p>
|
|
@@ -1153,15 +1165,29 @@ for (const [id, action] of [["btn-upgrade-apply", "apply"], ["btn-upgrade-declin
|
|
|
1153
1165
|
}
|
|
1154
1166
|
});
|
|
1155
1167
|
}
|
|
1168
|
+
const FAILURE_TYPES = { "429": "429 Too Many Requests", "503": "503 Service Unavailable", "5xx": "Other 5xx", timeout: "Timeout",
|
|
1169
|
+
connection: "Connection", auth: "Authentication (401/403)", other: "Other" };
|
|
1170
|
+
const PURPOSE_LABELS = { index_embedding: "Indexing", query_embedding: "Query", rerank: "Rerank", embedding_probe: "Model check", scope: "Scope" };
|
|
1171
|
+
function healthHtml(data) {
|
|
1172
|
+
const when = (at) => esc(new Date(at * 1000).toLocaleString());
|
|
1173
|
+
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("");
|
|
1174
|
+
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("");
|
|
1175
|
+
const groups = data.groups.map((group) => {
|
|
1176
|
+
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("");
|
|
1177
|
+
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>`
|
|
1178
|
+
+ `<p class="mono">${esc(group.sample)}</p><table><tr><th>When</th><th>Model</th><th>Tool</th><th>Error</th></tr>${rows}</table>`
|
|
1179
|
+
+ (group.count > group.rows.length ? `<p>Showing the latest ${group.rows.length} of ${group.count}.</p>` : "") + "</details>";
|
|
1180
|
+
}).join("");
|
|
1181
|
+
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>")
|
|
1182
|
+
+ (purposes ? `<h4>Calls by purpose</h4><table><tr><th>Purpose</th><th>Calls</th><th>Failed</th></tr>${purposes}</table>` : "")
|
|
1183
|
+
+ (groups ? `<h4>Failures by type</h4>${groups}` : "");
|
|
1184
|
+
}
|
|
1156
1185
|
async function loadHealth() {
|
|
1157
1186
|
try {
|
|
1158
1187
|
const data = await getJSON("/setup/models/health");
|
|
1159
1188
|
const failing = data.models.filter((row) => row.failing).length;
|
|
1160
1189
|
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>` : "");
|
|
1190
|
+
$("health-table").innerHTML = healthHtml(data);
|
|
1165
1191
|
} catch (error) {
|
|
1166
1192
|
setPill("health-pill", "bad", "Unavailable");
|
|
1167
1193
|
$("health-table").textContent = "Could not read the failure log: " + error.message;
|
|
@@ -1270,6 +1296,10 @@ const RULES = [
|
|
|
1270
1296
|
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
1297
|
options: [["warn", "Warn", "The edit runs; the agent is told when a function copies one already in the project.", true],
|
|
1272
1298
|
["off", "Off", "No duplicate checks."]] },
|
|
1299
|
+
{ key: "dependency_mode", title: "Vulnerable dependencies on edit", applies: "Applies on the next edit.",
|
|
1300
|
+
intro: "Looks up the dependencies an edit of a dependency manifest adds or changes in the OSV advisory database. It never blocks.",
|
|
1301
|
+
options: [["warn", "Warn", "The edit runs; the agent is told which added or changed dependencies have known advisories.", true],
|
|
1302
|
+
["off", "Off", "No dependency checks and no network call."]] },
|
|
1273
1303
|
{ key: "pattern_proposals", title: "Block patterns proposed by a model", applies: "Applies to the next review.",
|
|
1274
1304
|
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
1305
|
options: [["on", "On", "Review in the background and list proposals for you.", true],
|
|
@@ -1861,14 +1891,17 @@ async function loadAudit() {
|
|
|
1861
1891
|
}
|
|
1862
1892
|
renderOverview();
|
|
1863
1893
|
}
|
|
1894
|
+
function auditHookClient() { return $("sel-audit-hook-client").value; }
|
|
1895
|
+
$("sel-audit-hook-client").addEventListener("change", () => { setMsg("audit-hook-msg", "", ""); loadAuditHook(); });
|
|
1864
1896
|
async function loadAuditHook() {
|
|
1865
1897
|
try {
|
|
1866
|
-
const hook = await post("/setup/audit", { action: "hook_state" });
|
|
1898
|
+
const hook = await post("/setup/audit", { action: "hook_state", client: auditHookClient() });
|
|
1867
1899
|
$("audit-hook-file").textContent = hook.file;
|
|
1868
|
-
const on = hook.state === "current";
|
|
1900
|
+
const on = hook.state === "current" || hook.state === "waiting_approval";
|
|
1869
1901
|
const pillEl = $("audit-hook-pill");
|
|
1870
|
-
pillEl.textContent = on ? "On" : hook.state === "outdated" ? "Outdated" : "Off";
|
|
1871
|
-
pillEl.dataset.tone =
|
|
1902
|
+
pillEl.textContent = hook.state === "waiting_approval" ? "Waiting for approval in /hooks" : on ? "On" : hook.state === "outdated" ? "Outdated" : "Off";
|
|
1903
|
+
pillEl.dataset.tone = hook.state === "waiting_approval" || hook.state === "outdated" ? "warn" : on ? "ok" : "idle";
|
|
1904
|
+
if (hook.detail) setMsg("audit-hook-msg", "", hook.detail + ".");
|
|
1872
1905
|
$("btn-audit-hook-on").hidden = on;
|
|
1873
1906
|
$("btn-audit-hook-on").textContent = hook.state === "outdated" ? "Update" : "Turn on";
|
|
1874
1907
|
$("btn-audit-hook-off").hidden = hook.state === "missing";
|
|
@@ -1879,7 +1912,7 @@ async function loadAuditHook() {
|
|
|
1879
1912
|
$("btn-audit-hook-on").addEventListener("click", async () => {
|
|
1880
1913
|
setMsg("audit-hook-msg", "", "Preparing the change…");
|
|
1881
1914
|
try {
|
|
1882
|
-
const plan = await post("/setup/audit", { action: "hook_preview" });
|
|
1915
|
+
const plan = await post("/setup/audit", { action: "hook_preview", client: auditHookClient() });
|
|
1883
1916
|
setMsg("audit-hook-msg", "", "");
|
|
1884
1917
|
$("audit-hook-where").textContent = "Adds this entry under " + plan.event + " in " + plan.file + ".";
|
|
1885
1918
|
$("audit-hook-preview").textContent = JSON.stringify(plan.change, null, 2);
|
|
@@ -1887,10 +1920,10 @@ $("btn-audit-hook-on").addEventListener("click", async () => {
|
|
|
1887
1920
|
$("btn-audit-hook-write").onclick = async () => {
|
|
1888
1921
|
$("btn-audit-hook-write").disabled = true;
|
|
1889
1922
|
try {
|
|
1890
|
-
const result = await post("/setup/audit", { action: "hook_install", token: plan.token });
|
|
1923
|
+
const result = await post("/setup/audit", { action: "hook_install", token: plan.token, client: auditHookClient() });
|
|
1891
1924
|
$("audit-hook-confirm").hidden = true;
|
|
1892
1925
|
await loadAuditHook();
|
|
1893
|
-
setMsg("audit-hook-msg", "ok", "Turned on. Backup: " + (result.backup || "none, the file was new") + ".");
|
|
1926
|
+
setMsg("audit-hook-msg", "ok", "Turned on. Backup: " + (result.backup || "none, the file was new") + "." + (result.after_install ? " " + result.after_install : ""));
|
|
1894
1927
|
} catch (error) {
|
|
1895
1928
|
setMsg("audit-hook-msg", "error", error.message);
|
|
1896
1929
|
} finally {
|
|
@@ -1904,7 +1937,7 @@ $("btn-audit-hook-on").addEventListener("click", async () => {
|
|
|
1904
1937
|
$("btn-audit-hook-cancel").addEventListener("click", () => { $("audit-hook-confirm").hidden = true; });
|
|
1905
1938
|
$("btn-audit-hook-off").addEventListener("click", async () => {
|
|
1906
1939
|
try {
|
|
1907
|
-
const result = await post("/setup/audit", { action: "hook_remove" });
|
|
1940
|
+
const result = await post("/setup/audit", { action: "hook_remove", client: auditHookClient() });
|
|
1908
1941
|
await loadAuditHook();
|
|
1909
1942
|
setMsg("audit-hook-msg", "ok", "Turned off. Backup: " + (result.backup || "none") + ".");
|
|
1910
1943
|
} catch (error) {
|
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:
|