@allansantos-dev/smart-tool 0.9.0 → 0.9.2

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +50 -23
  2. package/NOTICE +32 -32
  3. package/README.md +117 -116
  4. package/atomic_io.py +94 -94
  5. package/bin/smart-tool.mjs +5 -1
  6. package/client_hooks.py +192 -192
  7. package/code_graph_java.mjs +49 -49
  8. package/code_graph_js.cjs +135 -135
  9. package/duplicates.py +313 -313
  10. package/embedding_cache.py +99 -99
  11. package/endpoint_sync.py +179 -179
  12. package/gateway.py +220 -220
  13. package/hook_decision.py +96 -96
  14. package/index_inventory.py +75 -75
  15. package/indexer.py +878 -878
  16. package/install.py +113 -11
  17. package/install_runtime.py +101 -35
  18. package/integration_metrics.py +82 -82
  19. package/local_embedder.py +172 -172
  20. package/model_defaults.py +70 -70
  21. package/package.json +35 -35
  22. package/project_insights_ui.py +38 -38
  23. package/project_monitor.py +204 -204
  24. package/project_store.py +220 -220
  25. package/projects_ui.py +146 -146
  26. package/requirements.txt +8 -8
  27. package/research_cache.py +171 -171
  28. package/research_metrics.py +110 -110
  29. package/router.py +582 -582
  30. package/secret_store.py +121 -121
  31. package/setup_ui.py +1011 -986
  32. package/smart_tool_daemon.py +3604 -3604
  33. package/version.py +1 -1
  34. package/web_adapters/browser/fetch_camoufox.py +69 -0
  35. package/web_adapters/node/ddg-search.mjs +89 -89
  36. package/web_adapters/node/open-websearch-adapter.mjs +77 -77
  37. package/web_adapters/node/package.json +12 -12
  38. package/web_adapters/node/wikipedia-search.mjs +44 -44
  39. package/web_search_adapters.py +193 -193
  40. package/web_search_external.py +288 -288
  41. package/web_search_health.py +88 -88
package/install.py CHANGED
@@ -3,6 +3,7 @@ import argparse
3
3
  import filecmp
4
4
  import json
5
5
  import os
6
+ import re
6
7
  import shutil
7
8
  import subprocess
8
9
  import sys
@@ -15,6 +16,10 @@ import paths
15
16
 
16
17
  SOURCE = Path(__file__).resolve().parent
17
18
  DEFAULT_TARGET = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) / "Programs" / "SmartTool"
19
+ TASK_NAME = "SmartTool-Tray"
20
+ ENTRY_SCRIPTS = ("tray.py", "smart_tool_daemon.py", "pretool_router.py")
21
+ STARTUP_SCRIPT = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming"))) / "Microsoft" / "Windows" / \
22
+ "Start Menu" / "Programs" / "Startup" / "SmartTool-Tray.vbs"
18
23
  MANIFEST = ".install-manifest.json"
19
24
  REGISTRY = Path(paths.DATA_DIR) / "daemon.json"
20
25
  BLOCKED = {"__pycache__", ".token-guard", ".git", ".pytest_cache", ".data", ".artifacts", ".claude", ".vscode",
@@ -23,12 +28,6 @@ BLOCKED = {"__pycache__", ".token-guard", ".git", ".pytest_cache", ".data", ".ar
23
28
  PACKAGE_ONLY = {"bin", "package.json", ".npmignore"}
24
29
  REQUIRED_TOOLS = {"smart_search", "smart_search_result", "web_search", "project_manage"}
25
30
  _OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
26
- _FIND = ("$tray = $env:SMART_TRAY.ToLowerInvariant(); $daemon = $env:SMART_DAEMON.ToLowerInvariant(); "
27
- "Get-CimInstance Win32_Process -Filter \"Name = 'pythonw.exe' OR Name = 'python.exe'\" | "
28
- "Where-Object { $_.CommandLine -and ($_.CommandLine.ToLowerInvariant().Contains($tray) -or "
29
- "$_.CommandLine.ToLowerInvariant().Contains($daemon)) }")
30
-
31
-
32
31
  def ships(rel):
33
32
  parts = Path(rel).parts
34
33
  *folders, name = parts
@@ -56,16 +55,41 @@ def powershell(script, env=None, timeout=30):
56
55
  capture_output=True, text=True, timeout=timeout)
57
56
 
58
57
 
58
+ def _processes():
59
+ """(pid, command line) of every python/pythonw process."""
60
+ listed = powershell("Get-CimInstance Win32_Process -Filter \"Name = 'pythonw.exe' OR Name = 'python.exe'\" | "
61
+ "ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }").stdout
62
+ rows = (row.partition("\t") for row in listed.splitlines())
63
+ return [(int(pid), line) for pid, _tab, line in rows if pid.strip().isdigit()]
64
+
65
+
66
+ def _script_folder(script):
67
+ path = Path(script)
68
+ return path.parent.parent if path.name == "pretool_router.py" else path.parent
69
+
70
+
71
+ def _same_folder(path, folder):
72
+ """Compares through resolve(), which turns 8.3 short and mixed spellings into the long name."""
73
+ try:
74
+ return path.resolve() == folder.resolve()
75
+ except OSError:
76
+ return False
77
+
78
+
59
79
  def stop_processes(target):
60
- env = {"SMART_TRAY": str(target / "tray.py"), "SMART_DAEMON": str(target / "smart_tool_daemon.py")}
61
- powershell(_FIND + " | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }", env=env)
80
+ def running():
81
+ return [pid for pid, line in _processes() for script in _script_paths(line)
82
+ if Path(script).name != "pretool_router.py" and _same_folder(_script_folder(script), target)]
83
+
84
+ for pid in running():
85
+ powershell(f"Stop-Process -Id {pid} -Force -ErrorAction SilentlyContinue")
62
86
  deadline = time.monotonic() + 10
63
87
  while True:
64
- left = powershell("@(" + _FIND + ").Count", env=env).stdout.strip()
65
- if left == "0":
88
+ left = running()
89
+ if not left:
66
90
  return
67
91
  if time.monotonic() > deadline:
68
- raise RuntimeError(f"Could not close the running Smart Tool ({left or '?'} process(es) still active).")
92
+ raise RuntimeError(f"Could not close the running Smart Tool ({len(left)} process(es) still active).")
69
93
  time.sleep(0.5)
70
94
 
71
95
 
@@ -86,6 +110,79 @@ def _daemon_up():
86
110
  return False
87
111
 
88
112
 
113
+ def hook_files():
114
+ claude = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
115
+ return claude / "settings.json", Path.home() / ".codex" / "hooks.json"
116
+
117
+
118
+ def _script_paths(text):
119
+ """Absolute paths ending in an entry script, as written in text, tried from every drive letter (paths may hold
120
+ spaces)."""
121
+ script = re.compile(r'[^"\r\n]*?(?:' + "|".join(map(re.escape, ENTRY_SCRIPTS)) + ")")
122
+ for drive in re.finditer(r"[A-Za-z]:[\\/]", text):
123
+ found = script.match(text, drive.start())
124
+ if found:
125
+ yield found.group(0)
126
+
127
+
128
+ def _install_folder(script):
129
+ folder = _script_folder(script)
130
+ try:
131
+ return folder.resolve() if (folder / MANIFEST).is_file() and (folder / "smart_tool_daemon.py").is_file() else None
132
+ except OSError:
133
+ return None
134
+
135
+
136
+ def _task_definition():
137
+ result = subprocess.run(["schtasks", "/Query", "/TN", TASK_NAME, "/XML"], capture_output=True, text=True,
138
+ timeout=20)
139
+ return result.stdout if result.returncode == 0 else ""
140
+
141
+
142
+ def other_installs(target):
143
+ """Installer-made Smart Tool folders other than target, found through what still runs or points at them: tray and
144
+ daemon processes, the logon task or Startup script, and agent hook commands."""
145
+ texts = [line for _pid, line in _processes()] + [_task_definition()]
146
+ texts += [path.read_text(encoding="utf-8", errors="replace") for path in (STARTUP_SCRIPT, *hook_files())
147
+ if path.is_file()]
148
+ folders = {_install_folder(script) for text in texts for script in _script_paths(text)}
149
+ return sorted(folder for folder in folders
150
+ if folder and not _same_folder(folder, target) and not _same_folder(folder, SOURCE))
151
+
152
+
153
+ def _repointed(text, folder, target):
154
+ """text with each spelling of folder found in front of an entry script (long, 8.3 or mixed) replaced by target."""
155
+ for script in set(_script_paths(text)):
156
+ cut = len(Path(script).name) + 1 + (len("hooks") + 1 if Path(script).name == "pretool_router.py" else 0)
157
+ spelled = script[:-cut]
158
+ if _same_folder(Path(spelled), folder):
159
+ text = text.replace(spelled, target.as_posix() if "/" in spelled else str(target))
160
+ return text
161
+
162
+
163
+ def remove_other_installs(target):
164
+ """An older installation in another folder keeps the port and the hooks: stop it, point the hooks at target and
165
+ delete its program folder. Its state folders are left alone."""
166
+ removed = []
167
+ stamp = time.strftime("%Y%m%d-%H%M%S")
168
+ for folder in other_installs(target):
169
+ print(f"Older Smart Tool installation found at {folder}; removing it.")
170
+ stop_processes(folder)
171
+ for path in hook_files():
172
+ original = path.read_text(encoding="utf-8") if path.is_file() else ""
173
+ text = _repointed(original, folder, target)
174
+ if text != original:
175
+ shutil.copy2(path, path.with_name(f"{path.name}.smart-tool-backup-{stamp}"))
176
+ path.write_text(text, encoding="utf-8")
177
+ print(f"Hooks in {path} now point to {target}.")
178
+ startup = STARTUP_SCRIPT.read_text(encoding="utf-8", errors="replace") if STARTUP_SCRIPT.is_file() else ""
179
+ if any(_same_folder(_script_folder(script), folder) for script in _script_paths(startup)):
180
+ STARTUP_SCRIPT.unlink()
181
+ shutil.rmtree(folder)
182
+ removed.append(folder)
183
+ return removed
184
+
185
+
89
186
  def copy_files(target, files, backup, changed, removed):
90
187
  previous = set(json.loads((target / MANIFEST).read_text(encoding="utf-8"))["files"]) if (target / MANIFEST).is_file() else set()
91
188
  if (target / MANIFEST).is_file():
@@ -105,6 +202,10 @@ def copy_files(target, files, backup, changed, removed):
105
202
  (backup / rel).parent.mkdir(parents=True, exist_ok=True)
106
203
  shutil.move(target / rel, backup / rel)
107
204
  removed.append(rel)
205
+ folder = (target / rel).parent
206
+ while folder != target and not any(folder.iterdir()):
207
+ folder.rmdir()
208
+ folder = folder.parent
108
209
  (target / MANIFEST).write_text(json.dumps({"installed_at": time.time(), "source": str(SOURCE), "files": files},
109
210
  indent=1), encoding="utf-8")
110
211
 
@@ -209,6 +310,7 @@ def main():
209
310
  existed = (target / "smart_tool_daemon.py").is_file()
210
311
  print(("Updating" if existed else "Installing") + f" Smart Tool at {target} ({len(files)} files from {SOURCE})")
211
312
  target.mkdir(parents=True, exist_ok=True)
313
+ remove_other_installs(target)
212
314
  stop_processes(target)
213
315
  if not args.no_start:
214
316
  wait_port_free()
@@ -5,6 +5,7 @@ Uso: python install_runtime.py [--check]
5
5
  Não altera ~/.claude nem instala pacotes npm globalmente.
6
6
  """
7
7
  import argparse
8
+ import collections
8
9
  import hashlib
9
10
  import json
10
11
  import os
@@ -13,6 +14,7 @@ import shutil
13
14
  import subprocess
14
15
  import sys
15
16
  import tempfile
17
+ import threading
16
18
  import time
17
19
  import urllib.request
18
20
  import venv
@@ -114,39 +116,73 @@ def _install_node():
114
116
  print(f"Node {version} installed inside Smart Tool.")
115
117
 
116
118
 
117
- def _run(args, *, cwd=None, label="Web dependency", timeout_s=600):
119
+ def _run(args, *, cwd=None, label="Web dependency", idle_s=600, stream=False, attempts=1):
120
+ """Runs a step with UTF-8 output and closed stdin (a prompt fails at once instead of hanging); it is stopped only
121
+ after idle_s without any output, and stream shows the output live."""
118
122
  env = os.environ.copy()
123
+ env.update(PYTHONUTF8="1", PYTHONIOENCODING="utf-8")
119
124
  if NODE_RUNTIME.is_dir():
120
125
  env["PATH"] = str(NODE_RUNTIME) + os.pathsep + env.get("PATH", "")
121
126
  flags = (subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW) if os.name == "nt" else 0
122
- print(f"{label}: starting...", flush=True)
123
- started = time.monotonic()
124
- process = subprocess.Popen(args, cwd=cwd, env=env, stdout=subprocess.PIPE,
125
- stderr=subprocess.PIPE, text=True, encoding="utf-8",
126
- errors="replace", creationflags=flags)
127
- while True:
128
- remaining = timeout_s - (time.monotonic() - started)
129
- if remaining <= 0:
130
- if os.name == "nt":
131
- subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
132
- capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
133
- else:
134
- process.kill()
135
- try:
136
- process.communicate(timeout=5)
137
- except subprocess.TimeoutExpired:
138
- process.kill()
139
- process.communicate(timeout=5)
140
- raise RuntimeError(f"{label} exceeded {timeout_s}s; check network, proxy and access to the package registry.")
127
+ for attempt in range(1, attempts + 1):
128
+ print(f"{label}: starting{f' (attempt {attempt} of {attempts})' if attempt > 1 else ''}...", flush=True)
141
129
  try:
142
- stdout, stderr = process.communicate(timeout=min(30, remaining))
143
- break
144
- except subprocess.TimeoutExpired:
145
- print(f"{label}: waiting for {int(time.monotonic() - started)}s...", flush=True)
146
- if process.returncode:
147
- detail = (stderr or stdout).strip()[-1500:]
148
- raise RuntimeError(f"{' '.join(map(str, args[:3]))} failed: {detail}")
149
- print(f"{label}: done.", flush=True)
130
+ _run_once(args, cwd, env, flags, label, idle_s, stream)
131
+ print(f"{label}: done.", flush=True)
132
+ return
133
+ except RuntimeError as exc:
134
+ if attempt == attempts:
135
+ raise
136
+ print(f"{exc}; trying again.", flush=True)
137
+
138
+
139
+ def _run_once(args, cwd, env, flags, label, idle_s, stream):
140
+ with subprocess.Popen(args, cwd=cwd, env=env, stdin=subprocess.DEVNULL,
141
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8",
142
+ errors="replace", creationflags=flags) as process:
143
+ tail = collections.deque(maxlen=40)
144
+ last_output = [time.monotonic()]
145
+ shown = {"stage": None, "tens": -1}
146
+
147
+ def pump():
148
+ for line in process.stdout:
149
+ line = line.rstrip()
150
+ if not line:
151
+ continue
152
+ tail.append(line)
153
+ last_output[0] = time.monotonic()
154
+ percent = re.match(r"(.*?)(\d{1,3})%$", line)
155
+ if percent:
156
+ stage, tens = percent.group(1), int(percent.group(2)) // 10
157
+ if stage == shown["stage"] and tens <= shown["tens"]:
158
+ continue
159
+ shown.update(stage=stage, tens=tens)
160
+ if stream:
161
+ print(f"{label}: {line}", flush=True)
162
+
163
+ reader = threading.Thread(target=pump, daemon=True)
164
+ reader.start()
165
+ started = noted = time.monotonic()
166
+ while process.poll() is None:
167
+ now = time.monotonic()
168
+ if now - last_output[0] > idle_s:
169
+ if os.name == "nt":
170
+ subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
171
+ capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
172
+ else:
173
+ process.kill()
174
+ process.wait(timeout=10)
175
+ raise RuntimeError(f"{label} stopped: no output for {idle_s}s; last output: {tail[-1] if tail else 'none'}")
176
+ if not stream and now - noted >= 30:
177
+ noted = now
178
+ print(f"{label}: waiting for {int(now - started)}s...", flush=True)
179
+ time.sleep(0.5)
180
+ reader.join(timeout=10)
181
+ if process.returncode:
182
+ if not stream:
183
+ for line in list(tail)[-15:]:
184
+ print(f"{label}: {line}", flush=True)
185
+ raise RuntimeError(f"{label} failed (exit {process.returncode}): {tail[-1] if tail else 'no output'}")
150
186
 
151
187
 
152
188
  def _check():
@@ -159,6 +195,8 @@ def _check():
159
195
  "angular_parser":bool(node and ANGULAR_PARSER.is_file()),
160
196
  "browser_python": BROWSER_PYTHON.is_file(),
161
197
  "browser_packages": False,
198
+ "camoufox_browser": False,
199
+ "chromium_browser": False,
162
200
  }
163
201
  if status["browser_python"]:
164
202
  try:
@@ -169,9 +207,36 @@ def _check():
169
207
  status["browser_packages"] = result.returncode == 0
170
208
  except subprocess.TimeoutExpired:
171
209
  status["browser_packages"] = False
210
+ if status["browser_packages"]:
211
+ status.update(_browser_executables())
172
212
  return status
173
213
 
174
214
 
215
+ _BROWSERS_PROBE = """
216
+ import json, os
217
+ from camoufox.pkgman import LAUNCH_FILE, OS_NAME, camoufox_path
218
+ from playwright.sync_api import sync_playwright
219
+ found = {}
220
+ try:
221
+ found["camoufox_browser"] = os.path.isfile(os.path.join(camoufox_path(download_if_missing=False), LAUNCH_FILE[OS_NAME]))
222
+ except Exception:
223
+ found["camoufox_browser"] = False
224
+ with sync_playwright() as playwright:
225
+ found["chromium_browser"] = os.path.isfile(playwright.chromium.executable_path)
226
+ print(json.dumps(found))
227
+ """
228
+
229
+
230
+ def _browser_executables():
231
+ """Both browsers must exist on disk: a download tool reporting success is not enough."""
232
+ try:
233
+ result = subprocess.run([str(BROWSER_PYTHON), "-c", _BROWSERS_PROBE], capture_output=True, text=True,
234
+ encoding="utf-8", errors="replace", check=False, timeout=60)
235
+ return json.loads(result.stdout.strip().splitlines()[-1])
236
+ except (subprocess.TimeoutExpired, ValueError, IndexError):
237
+ return {"camoufox_browser": False, "chromium_browser": False}
238
+
239
+
175
240
  def _link_existing_browser(existing, link):
176
241
  try:
177
242
  os.symlink(existing, link, target_is_directory=True)
@@ -199,7 +264,7 @@ def install(reuse_browser_runtime=None):
199
264
  node, npm = _node_commands()
200
265
  if not all(path.is_file() for path in (OPEN_WEBSEARCH_CLI,TYPESCRIPT_PARSER,JAVA_PARSER,ANGULAR_PARSER)):
201
266
  _run([npm, "ci", "--omit=dev", "--no-audit", "--no-fund"], cwd=NODE_DIR,
202
- label="Web adapters and code parser via npm", timeout_s=300)
267
+ label="Web adapters and code parser via npm", idle_s=300)
203
268
  if reuse_browser_runtime:
204
269
  existing = Path(reuse_browser_runtime).expanduser().resolve()
205
270
  if not (existing / "Scripts" / "python.exe").is_file():
@@ -214,13 +279,13 @@ def install(reuse_browser_runtime=None):
214
279
  if not _check()["browser_packages"]:
215
280
  _run([str(BROWSER_PYTHON), "-m", "pip", "install", "--disable-pip-version-check",
216
281
  "--no-input", "-r", str(BROWSER_DIR / "requirements.txt")],
217
- label="Camoufox and Crawl4AI via pip", timeout_s=600)
282
+ label="Camoufox and Crawl4AI via pip", idle_s=600)
218
283
  # Camoufox usa Firefox modificado; Crawl4AI usa Chromium via Playwright.
219
284
  if not reuse_browser_runtime:
220
- _run([str(BROWSER_PYTHON), "-m", "camoufox", "fetch"],
221
- label="Camoufox browser", timeout_s=600)
285
+ _run([str(BROWSER_PYTHON), str(BROWSER_DIR / "fetch_camoufox.py")], label="Camoufox browser (1.3 GB)",
286
+ idle_s=180, stream=True, attempts=3)
222
287
  _run([str(BROWSER_PYTHON), "-m", "playwright", "install", "chromium"],
223
- label="Chromium browser", timeout_s=600)
288
+ label="Chromium browser", idle_s=180, stream=True, attempts=3)
224
289
  status = _check()
225
290
  if not all(status.values()):
226
291
  raise RuntimeError(f"Incomplete web runtime: {status}")
@@ -234,9 +299,10 @@ def main():
234
299
  help="Reuse an existing venv without duplicating files (useful when disk is low)")
235
300
  args = parser.parse_args()
236
301
  if args.check:
237
- for name, ready in _check().items():
302
+ status = _check()
303
+ for name, ready in status.items():
238
304
  print(f"{name}: {'ok' if ready else 'missing'}")
239
- return 0 if all(_check().values()) else 1
305
+ return 0 if all(status.values()) else 1
240
306
  try:
241
307
  install(args.reuse_browser_runtime)
242
308
  except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
@@ -1,82 +1,82 @@
1
- import collections
2
- import json
3
- import os
4
- import paths
5
- import threading
6
- import time
7
-
8
- import atomic_io
9
- import router
10
-
11
- PATH = os.path.join(paths.DATA_DIR, "integration-metrics.jsonl")
12
- MAX_BYTES = 20 * 1024 * 1024
13
- _LOCK = threading.Lock()
14
-
15
-
16
- def record(event, **fields):
17
- line = json.dumps({"ts": time.time(), "event": event, **fields}, ensure_ascii=False)
18
- with _LOCK:
19
- os.makedirs(os.path.dirname(PATH), exist_ok=True)
20
- atomic_io.rotate(PATH, MAX_BYTES)
21
- with open(PATH, "a", encoding="utf-8") as stream:
22
- stream.write(line + "\n")
23
-
24
-
25
- def _read(path, since):
26
- rows = []
27
- for candidate in (path + ".1", path):
28
- try:
29
- with open(candidate, encoding="utf-8") as stream:
30
- for line in stream:
31
- try:
32
- row = json.loads(line)
33
- except ValueError:
34
- continue
35
- if isinstance(row, dict) and isinstance(row.get("ts"), (int, float)) and row["ts"] >= since:
36
- rows.append(row)
37
- except FileNotFoundError:
38
- continue
39
- return rows
40
-
41
-
42
- def summary(days=7):
43
- since = time.time() - days * 86400
44
- events = _read(PATH, since)
45
- by_client = collections.defaultdict(collections.Counter)
46
- reasons, rejects = collections.Counter(), collections.Counter()
47
- for row in events:
48
- by_client[row.get("client") or "desconhecido"][row.get("event", "?")] += 1
49
- if row.get("event") == "search" and row.get("single_language_reason"):
50
- reasons[row["single_language_reason"][:120]] += 1
51
- if row.get("event") == "reject":
52
- rejects[row.get("kind", "outro")] += 1
53
- web = [row for row in events if row.get("event") == "web_search"]
54
- origin = lambda row: "error" if row.get("error") else row.get("cache", "?")
55
- by_cache = collections.Counter(origin(row) for row in web)
56
- elapsed = collections.defaultdict(list)
57
- for row in web:
58
- if isinstance(row.get("elapsed_s"), (int, float)):
59
- elapsed[origin(row)].append(row["elapsed_s"])
60
- reused = sum(count for status, count in by_cache.items() if status not in ("fresh", "error"))
61
- web_cache = {
62
- "calls": len(web), "reused": reused, "reuse_rate": round(reused / len(web), 3) if web else None,
63
- "by_cache": dict(by_cache),
64
- "probes": sum(1 for row in web if row.get("probe")),
65
- "probe_verdicts": dict(collections.Counter(row["verdict"] for row in web if row.get("verdict"))),
66
- "errors": dict(collections.Counter(row["error"] for row in web if row.get("error"))),
67
- "reused_volatility": dict(collections.Counter(row.get("volatility") or "?" for row in web
68
- if origin(row) not in ("fresh", "error"))),
69
- "bilingual": sum(1 for row in web if row.get("bilingual")),
70
- "degraded": sum(1 for row in web if row.get("degraded")),
71
- "with_warnings": sum(1 for row in web if row.get("warnings")),
72
- "avg_elapsed_s": {status: round(sum(values) / len(values), 2) for status, values in elapsed.items()},
73
- }
74
- routed = _read(router.METRICS_PATH, since)
75
- decisions = collections.defaultdict(collections.Counter)
76
- for row in routed:
77
- decisions[row.get("client") or "no client"][row.get("decision", "?")] += 1
78
- return {"days": days, "events_by_client": {k: dict(v) for k, v in by_client.items()},
79
- "rejects_by_kind": dict(rejects), "single_language_reasons": dict(reasons.most_common(20)),
80
- "hook_decisions_by_client": {k: dict(v) for k, v in decisions.items()},
81
- "web_search_cache": web_cache,
82
- "files": {"integration": PATH, "router": router.METRICS_PATH}}
1
+ import collections
2
+ import json
3
+ import os
4
+ import paths
5
+ import threading
6
+ import time
7
+
8
+ import atomic_io
9
+ import router
10
+
11
+ PATH = os.path.join(paths.DATA_DIR, "integration-metrics.jsonl")
12
+ MAX_BYTES = 20 * 1024 * 1024
13
+ _LOCK = threading.Lock()
14
+
15
+
16
+ def record(event, **fields):
17
+ line = json.dumps({"ts": time.time(), "event": event, **fields}, ensure_ascii=False)
18
+ with _LOCK:
19
+ os.makedirs(os.path.dirname(PATH), exist_ok=True)
20
+ atomic_io.rotate(PATH, MAX_BYTES)
21
+ with open(PATH, "a", encoding="utf-8") as stream:
22
+ stream.write(line + "\n")
23
+
24
+
25
+ def _read(path, since):
26
+ rows = []
27
+ for candidate in (path + ".1", path):
28
+ try:
29
+ with open(candidate, encoding="utf-8") as stream:
30
+ for line in stream:
31
+ try:
32
+ row = json.loads(line)
33
+ except ValueError:
34
+ continue
35
+ if isinstance(row, dict) and isinstance(row.get("ts"), (int, float)) and row["ts"] >= since:
36
+ rows.append(row)
37
+ except FileNotFoundError:
38
+ continue
39
+ return rows
40
+
41
+
42
+ def summary(days=7):
43
+ since = time.time() - days * 86400
44
+ events = _read(PATH, since)
45
+ by_client = collections.defaultdict(collections.Counter)
46
+ reasons, rejects = collections.Counter(), collections.Counter()
47
+ for row in events:
48
+ by_client[row.get("client") or "desconhecido"][row.get("event", "?")] += 1
49
+ if row.get("event") == "search" and row.get("single_language_reason"):
50
+ reasons[row["single_language_reason"][:120]] += 1
51
+ if row.get("event") == "reject":
52
+ rejects[row.get("kind", "outro")] += 1
53
+ web = [row for row in events if row.get("event") == "web_search"]
54
+ origin = lambda row: "error" if row.get("error") else row.get("cache", "?")
55
+ by_cache = collections.Counter(origin(row) for row in web)
56
+ elapsed = collections.defaultdict(list)
57
+ for row in web:
58
+ if isinstance(row.get("elapsed_s"), (int, float)):
59
+ elapsed[origin(row)].append(row["elapsed_s"])
60
+ reused = sum(count for status, count in by_cache.items() if status not in ("fresh", "error"))
61
+ web_cache = {
62
+ "calls": len(web), "reused": reused, "reuse_rate": round(reused / len(web), 3) if web else None,
63
+ "by_cache": dict(by_cache),
64
+ "probes": sum(1 for row in web if row.get("probe")),
65
+ "probe_verdicts": dict(collections.Counter(row["verdict"] for row in web if row.get("verdict"))),
66
+ "errors": dict(collections.Counter(row["error"] for row in web if row.get("error"))),
67
+ "reused_volatility": dict(collections.Counter(row.get("volatility") or "?" for row in web
68
+ if origin(row) not in ("fresh", "error"))),
69
+ "bilingual": sum(1 for row in web if row.get("bilingual")),
70
+ "degraded": sum(1 for row in web if row.get("degraded")),
71
+ "with_warnings": sum(1 for row in web if row.get("warnings")),
72
+ "avg_elapsed_s": {status: round(sum(values) / len(values), 2) for status, values in elapsed.items()},
73
+ }
74
+ routed = _read(router.METRICS_PATH, since)
75
+ decisions = collections.defaultdict(collections.Counter)
76
+ for row in routed:
77
+ decisions[row.get("client") or "no client"][row.get("decision", "?")] += 1
78
+ return {"days": days, "events_by_client": {k: dict(v) for k, v in by_client.items()},
79
+ "rejects_by_kind": dict(rejects), "single_language_reasons": dict(reasons.most_common(20)),
80
+ "hook_decisions_by_client": {k: dict(v) for k, v in decisions.items()},
81
+ "web_search_cache": web_cache,
82
+ "files": {"integration": PATH, "router": router.METRICS_PATH}}