@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.
- package/CHANGELOG.md +50 -23
- package/NOTICE +32 -32
- package/README.md +117 -116
- package/atomic_io.py +94 -94
- package/bin/smart-tool.mjs +5 -1
- package/client_hooks.py +192 -192
- package/code_graph_java.mjs +49 -49
- package/code_graph_js.cjs +135 -135
- package/duplicates.py +313 -313
- package/embedding_cache.py +99 -99
- package/endpoint_sync.py +179 -179
- package/gateway.py +220 -220
- package/hook_decision.py +96 -96
- package/index_inventory.py +75 -75
- package/indexer.py +878 -878
- package/install.py +113 -11
- package/install_runtime.py +101 -35
- package/integration_metrics.py +82 -82
- package/local_embedder.py +172 -172
- package/model_defaults.py +70 -70
- package/package.json +35 -35
- package/project_insights_ui.py +38 -38
- package/project_monitor.py +204 -204
- package/project_store.py +220 -220
- package/projects_ui.py +146 -146
- package/requirements.txt +8 -8
- package/research_cache.py +171 -171
- package/research_metrics.py +110 -110
- package/router.py +582 -582
- package/secret_store.py +121 -121
- package/setup_ui.py +1011 -986
- package/smart_tool_daemon.py +3604 -3604
- package/version.py +1 -1
- package/web_adapters/browser/fetch_camoufox.py +69 -0
- package/web_adapters/node/ddg-search.mjs +89 -89
- package/web_adapters/node/open-websearch-adapter.mjs +77 -77
- package/web_adapters/node/package.json +12 -12
- package/web_adapters/node/wikipedia-search.mjs +44 -44
- package/web_search_adapters.py +193 -193
- package/web_search_external.py +288 -288
- package/web_search_health.py +88 -88
package/research_cache.py
CHANGED
|
@@ -1,171 +1,171 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Pesquisas reutilizáveis com conteúdo protegido por DPAPI no perfil Windows.
|
|
3
|
-
|
|
4
|
-
O SQLite guarda apenas hash da consulta, horários e um blob cifrado. Consultas,
|
|
5
|
-
trechos, achados e URLs ficam dentro do blob. Falha de leitura/gravação desliga o
|
|
6
|
-
reúso nesta chamada; a pesquisa web continua normalmente.
|
|
7
|
-
"""
|
|
8
|
-
import array
|
|
9
|
-
import base64
|
|
10
|
-
import hashlib
|
|
11
|
-
import json
|
|
12
|
-
import os
|
|
13
|
-
import paths
|
|
14
|
-
import sqlite3
|
|
15
|
-
import threading
|
|
16
|
-
import time
|
|
17
|
-
from contextlib import contextmanager
|
|
18
|
-
|
|
19
|
-
import secret_store
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
CACHE_PATH = os.path.join(paths.DATA_DIR, "research-cache.sqlite3")
|
|
23
|
-
MAX_ENTRIES = 500
|
|
24
|
-
# Namespaces with their own quota stay out of MAX_ENTRIES, so web_fetch pages never evict web_search answers.
|
|
25
|
-
OWN_QUOTA = {"web_fetch": 300}
|
|
26
|
-
SCHEMA = 2
|
|
27
|
-
MAX_AGE_S = 30 * 86400
|
|
28
|
-
_LOCK = threading.RLock()
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _key(depth, query):
|
|
32
|
-
normalized = " ".join(str(query or "").casefold().split())
|
|
33
|
-
return hashlib.sha256(f"{depth}\0{normalized}".encode("utf-8")).hexdigest()
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _connect():
|
|
37
|
-
os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
|
|
38
|
-
connection = sqlite3.connect(CACHE_PATH, timeout=5)
|
|
39
|
-
connection.execute("""
|
|
40
|
-
CREATE TABLE IF NOT EXISTS entries (
|
|
41
|
-
id TEXT PRIMARY KEY,
|
|
42
|
-
depth TEXT NOT NULL,
|
|
43
|
-
payload TEXT NOT NULL,
|
|
44
|
-
saved_at REAL NOT NULL,
|
|
45
|
-
validated_at REAL NOT NULL
|
|
46
|
-
)
|
|
47
|
-
""")
|
|
48
|
-
return connection
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
@contextmanager
|
|
52
|
-
def _connection():
|
|
53
|
-
db = _connect()
|
|
54
|
-
try:
|
|
55
|
-
with db:
|
|
56
|
-
yield db
|
|
57
|
-
finally:
|
|
58
|
-
db.close()
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def candidates(depth, query, limit=200):
|
|
62
|
-
"""Entrada exata primeiro, depois recentes para confirmação semântica."""
|
|
63
|
-
if not secret_store.available():
|
|
64
|
-
return []
|
|
65
|
-
key = _key(depth, query)
|
|
66
|
-
try:
|
|
67
|
-
with _LOCK, _connection() as db:
|
|
68
|
-
db.execute("DELETE FROM entries WHERE saved_at < ?", (time.time() - MAX_AGE_S,))
|
|
69
|
-
rows = db.execute(
|
|
70
|
-
"SELECT id, payload, saved_at, validated_at FROM entries "
|
|
71
|
-
"WHERE depth = ? ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END, "
|
|
72
|
-
"validated_at DESC LIMIT ?", (depth, key, limit),
|
|
73
|
-
).fetchall()
|
|
74
|
-
except (OSError, sqlite3.Error):
|
|
75
|
-
return []
|
|
76
|
-
result = []
|
|
77
|
-
for entry_id, protected, saved_at, validated_at in rows:
|
|
78
|
-
try:
|
|
79
|
-
if not secret_store.is_protected(protected):
|
|
80
|
-
continue
|
|
81
|
-
payload = json.loads(secret_store.unprotect(protected))
|
|
82
|
-
if not isinstance(payload, dict) or payload.get("schema") != SCHEMA:
|
|
83
|
-
continue
|
|
84
|
-
vector = payload.pop("topic_vector", None)
|
|
85
|
-
if isinstance(vector, dict):
|
|
86
|
-
values = array.array("f")
|
|
87
|
-
values.frombytes(base64.b64decode(vector["b64"]))
|
|
88
|
-
payload["topic_vector"] = {"model": vector["model"], "values": values}
|
|
89
|
-
result.append({"id": entry_id, "saved_at": saved_at,
|
|
90
|
-
"validated_at": validated_at, **payload})
|
|
91
|
-
except (ValueError, TypeError, KeyError, secret_store.SecretProtectionError):
|
|
92
|
-
continue
|
|
93
|
-
return result
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _packed_vector(model, vector):
|
|
97
|
-
if not model or not vector:
|
|
98
|
-
return None
|
|
99
|
-
return {"model": model, "b64": base64.b64encode(array.array("f", vector).tobytes()).decode("ascii")}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def put(depth, query, result, classification, topic_model=None, topic_vector=None, origin=None):
|
|
103
|
-
"""Grava a pesquisa e devolve o horário da gravação, ou None se não gravou."""
|
|
104
|
-
if not secret_store.available():
|
|
105
|
-
return None
|
|
106
|
-
now = time.time()
|
|
107
|
-
saved_at, validated_at = (origin["saved_at"], origin["validated_at"]) if origin else (now, now)
|
|
108
|
-
payload = {"schema": SCHEMA, "query": query, "depth": depth,
|
|
109
|
-
"result": result, "classification": classification}
|
|
110
|
-
if origin:
|
|
111
|
-
payload["origin_query"] = origin.get("origin_query") or origin["query"]
|
|
112
|
-
packed = _packed_vector(topic_model, topic_vector)
|
|
113
|
-
if packed:
|
|
114
|
-
payload["topic_vector"] = packed
|
|
115
|
-
try:
|
|
116
|
-
protected = secret_store.protect(json.dumps(payload, ensure_ascii=False))
|
|
117
|
-
with _LOCK, _connection() as db:
|
|
118
|
-
db.execute(
|
|
119
|
-
"INSERT OR REPLACE INTO entries(id, depth, payload, saved_at, validated_at) "
|
|
120
|
-
"VALUES (?, ?, ?, ?, ?)", (_key(depth, query), depth, protected, saved_at, validated_at),
|
|
121
|
-
)
|
|
122
|
-
own = sorted(OWN_QUOTA)
|
|
123
|
-
marks = ",".join("?" * len(own))
|
|
124
|
-
if depth in OWN_QUOTA:
|
|
125
|
-
db.execute("DELETE FROM entries WHERE depth = ? AND id NOT IN "
|
|
126
|
-
"(SELECT id FROM entries WHERE depth = ? ORDER BY saved_at DESC LIMIT ?)",
|
|
127
|
-
(depth, depth, OWN_QUOTA[depth]))
|
|
128
|
-
else:
|
|
129
|
-
db.execute(f"DELETE FROM entries WHERE depth NOT IN ({marks}) AND id NOT IN "
|
|
130
|
-
f"(SELECT id FROM entries WHERE depth NOT IN ({marks}) ORDER BY saved_at DESC LIMIT ?)",
|
|
131
|
-
(*own, *own, MAX_ENTRIES))
|
|
132
|
-
return saved_at
|
|
133
|
-
except (OSError, ValueError, TypeError, sqlite3.Error, secret_store.SecretProtectionError):
|
|
134
|
-
return None
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def complete(depth, query, saved_at, classification, topic_model=None, topic_vector=None):
|
|
138
|
-
"""Completa a entrada gravada em saved_at; uma regravação posterior não é sobrescrita."""
|
|
139
|
-
try:
|
|
140
|
-
with _LOCK, _connection() as db:
|
|
141
|
-
row = db.execute("SELECT payload FROM entries WHERE id = ? AND saved_at = ?",
|
|
142
|
-
(_key(depth, query), saved_at)).fetchone()
|
|
143
|
-
if row is None:
|
|
144
|
-
return False
|
|
145
|
-
payload = json.loads(secret_store.unprotect(row[0]))
|
|
146
|
-
payload["classification"] = classification
|
|
147
|
-
packed = _packed_vector(topic_model, topic_vector)
|
|
148
|
-
if packed:
|
|
149
|
-
payload["topic_vector"] = packed
|
|
150
|
-
db.execute("UPDATE entries SET payload = ? WHERE id = ? AND saved_at = ?",
|
|
151
|
-
(secret_store.protect(json.dumps(payload, ensure_ascii=False)),
|
|
152
|
-
_key(depth, query), saved_at))
|
|
153
|
-
return True
|
|
154
|
-
except (OSError, ValueError, TypeError, sqlite3.Error, secret_store.SecretProtectionError):
|
|
155
|
-
return False
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def mark_validated(entry_id):
|
|
159
|
-
try:
|
|
160
|
-
with _LOCK, _connection() as db:
|
|
161
|
-
db.execute("UPDATE entries SET validated_at = ? WHERE id = ?", (time.time(), entry_id))
|
|
162
|
-
except (OSError, sqlite3.Error):
|
|
163
|
-
pass
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def discard(entry_id):
|
|
167
|
-
try:
|
|
168
|
-
with _LOCK, _connection() as db:
|
|
169
|
-
db.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
|
|
170
|
-
except (OSError, sqlite3.Error):
|
|
171
|
-
pass
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pesquisas reutilizáveis com conteúdo protegido por DPAPI no perfil Windows.
|
|
3
|
+
|
|
4
|
+
O SQLite guarda apenas hash da consulta, horários e um blob cifrado. Consultas,
|
|
5
|
+
trechos, achados e URLs ficam dentro do blob. Falha de leitura/gravação desliga o
|
|
6
|
+
reúso nesta chamada; a pesquisa web continua normalmente.
|
|
7
|
+
"""
|
|
8
|
+
import array
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import paths
|
|
14
|
+
import sqlite3
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
|
|
19
|
+
import secret_store
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
CACHE_PATH = os.path.join(paths.DATA_DIR, "research-cache.sqlite3")
|
|
23
|
+
MAX_ENTRIES = 500
|
|
24
|
+
# Namespaces with their own quota stay out of MAX_ENTRIES, so web_fetch pages never evict web_search answers.
|
|
25
|
+
OWN_QUOTA = {"web_fetch": 300}
|
|
26
|
+
SCHEMA = 2
|
|
27
|
+
MAX_AGE_S = 30 * 86400
|
|
28
|
+
_LOCK = threading.RLock()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _key(depth, query):
|
|
32
|
+
normalized = " ".join(str(query or "").casefold().split())
|
|
33
|
+
return hashlib.sha256(f"{depth}\0{normalized}".encode("utf-8")).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _connect():
|
|
37
|
+
os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
|
|
38
|
+
connection = sqlite3.connect(CACHE_PATH, timeout=5)
|
|
39
|
+
connection.execute("""
|
|
40
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
depth TEXT NOT NULL,
|
|
43
|
+
payload TEXT NOT NULL,
|
|
44
|
+
saved_at REAL NOT NULL,
|
|
45
|
+
validated_at REAL NOT NULL
|
|
46
|
+
)
|
|
47
|
+
""")
|
|
48
|
+
return connection
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@contextmanager
|
|
52
|
+
def _connection():
|
|
53
|
+
db = _connect()
|
|
54
|
+
try:
|
|
55
|
+
with db:
|
|
56
|
+
yield db
|
|
57
|
+
finally:
|
|
58
|
+
db.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def candidates(depth, query, limit=200):
|
|
62
|
+
"""Entrada exata primeiro, depois recentes para confirmação semântica."""
|
|
63
|
+
if not secret_store.available():
|
|
64
|
+
return []
|
|
65
|
+
key = _key(depth, query)
|
|
66
|
+
try:
|
|
67
|
+
with _LOCK, _connection() as db:
|
|
68
|
+
db.execute("DELETE FROM entries WHERE saved_at < ?", (time.time() - MAX_AGE_S,))
|
|
69
|
+
rows = db.execute(
|
|
70
|
+
"SELECT id, payload, saved_at, validated_at FROM entries "
|
|
71
|
+
"WHERE depth = ? ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END, "
|
|
72
|
+
"validated_at DESC LIMIT ?", (depth, key, limit),
|
|
73
|
+
).fetchall()
|
|
74
|
+
except (OSError, sqlite3.Error):
|
|
75
|
+
return []
|
|
76
|
+
result = []
|
|
77
|
+
for entry_id, protected, saved_at, validated_at in rows:
|
|
78
|
+
try:
|
|
79
|
+
if not secret_store.is_protected(protected):
|
|
80
|
+
continue
|
|
81
|
+
payload = json.loads(secret_store.unprotect(protected))
|
|
82
|
+
if not isinstance(payload, dict) or payload.get("schema") != SCHEMA:
|
|
83
|
+
continue
|
|
84
|
+
vector = payload.pop("topic_vector", None)
|
|
85
|
+
if isinstance(vector, dict):
|
|
86
|
+
values = array.array("f")
|
|
87
|
+
values.frombytes(base64.b64decode(vector["b64"]))
|
|
88
|
+
payload["topic_vector"] = {"model": vector["model"], "values": values}
|
|
89
|
+
result.append({"id": entry_id, "saved_at": saved_at,
|
|
90
|
+
"validated_at": validated_at, **payload})
|
|
91
|
+
except (ValueError, TypeError, KeyError, secret_store.SecretProtectionError):
|
|
92
|
+
continue
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _packed_vector(model, vector):
|
|
97
|
+
if not model or not vector:
|
|
98
|
+
return None
|
|
99
|
+
return {"model": model, "b64": base64.b64encode(array.array("f", vector).tobytes()).decode("ascii")}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def put(depth, query, result, classification, topic_model=None, topic_vector=None, origin=None):
|
|
103
|
+
"""Grava a pesquisa e devolve o horário da gravação, ou None se não gravou."""
|
|
104
|
+
if not secret_store.available():
|
|
105
|
+
return None
|
|
106
|
+
now = time.time()
|
|
107
|
+
saved_at, validated_at = (origin["saved_at"], origin["validated_at"]) if origin else (now, now)
|
|
108
|
+
payload = {"schema": SCHEMA, "query": query, "depth": depth,
|
|
109
|
+
"result": result, "classification": classification}
|
|
110
|
+
if origin:
|
|
111
|
+
payload["origin_query"] = origin.get("origin_query") or origin["query"]
|
|
112
|
+
packed = _packed_vector(topic_model, topic_vector)
|
|
113
|
+
if packed:
|
|
114
|
+
payload["topic_vector"] = packed
|
|
115
|
+
try:
|
|
116
|
+
protected = secret_store.protect(json.dumps(payload, ensure_ascii=False))
|
|
117
|
+
with _LOCK, _connection() as db:
|
|
118
|
+
db.execute(
|
|
119
|
+
"INSERT OR REPLACE INTO entries(id, depth, payload, saved_at, validated_at) "
|
|
120
|
+
"VALUES (?, ?, ?, ?, ?)", (_key(depth, query), depth, protected, saved_at, validated_at),
|
|
121
|
+
)
|
|
122
|
+
own = sorted(OWN_QUOTA)
|
|
123
|
+
marks = ",".join("?" * len(own))
|
|
124
|
+
if depth in OWN_QUOTA:
|
|
125
|
+
db.execute("DELETE FROM entries WHERE depth = ? AND id NOT IN "
|
|
126
|
+
"(SELECT id FROM entries WHERE depth = ? ORDER BY saved_at DESC LIMIT ?)",
|
|
127
|
+
(depth, depth, OWN_QUOTA[depth]))
|
|
128
|
+
else:
|
|
129
|
+
db.execute(f"DELETE FROM entries WHERE depth NOT IN ({marks}) AND id NOT IN "
|
|
130
|
+
f"(SELECT id FROM entries WHERE depth NOT IN ({marks}) ORDER BY saved_at DESC LIMIT ?)",
|
|
131
|
+
(*own, *own, MAX_ENTRIES))
|
|
132
|
+
return saved_at
|
|
133
|
+
except (OSError, ValueError, TypeError, sqlite3.Error, secret_store.SecretProtectionError):
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def complete(depth, query, saved_at, classification, topic_model=None, topic_vector=None):
|
|
138
|
+
"""Completa a entrada gravada em saved_at; uma regravação posterior não é sobrescrita."""
|
|
139
|
+
try:
|
|
140
|
+
with _LOCK, _connection() as db:
|
|
141
|
+
row = db.execute("SELECT payload FROM entries WHERE id = ? AND saved_at = ?",
|
|
142
|
+
(_key(depth, query), saved_at)).fetchone()
|
|
143
|
+
if row is None:
|
|
144
|
+
return False
|
|
145
|
+
payload = json.loads(secret_store.unprotect(row[0]))
|
|
146
|
+
payload["classification"] = classification
|
|
147
|
+
packed = _packed_vector(topic_model, topic_vector)
|
|
148
|
+
if packed:
|
|
149
|
+
payload["topic_vector"] = packed
|
|
150
|
+
db.execute("UPDATE entries SET payload = ? WHERE id = ? AND saved_at = ?",
|
|
151
|
+
(secret_store.protect(json.dumps(payload, ensure_ascii=False)),
|
|
152
|
+
_key(depth, query), saved_at))
|
|
153
|
+
return True
|
|
154
|
+
except (OSError, ValueError, TypeError, sqlite3.Error, secret_store.SecretProtectionError):
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def mark_validated(entry_id):
|
|
159
|
+
try:
|
|
160
|
+
with _LOCK, _connection() as db:
|
|
161
|
+
db.execute("UPDATE entries SET validated_at = ? WHERE id = ?", (time.time(), entry_id))
|
|
162
|
+
except (OSError, sqlite3.Error):
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def discard(entry_id):
|
|
167
|
+
try:
|
|
168
|
+
with _LOCK, _connection() as db:
|
|
169
|
+
db.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
|
|
170
|
+
except (OSError, sqlite3.Error):
|
|
171
|
+
pass
|
package/research_metrics.py
CHANGED
|
@@ -1,110 +1,110 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Trilha de calibração da memória (cosseno + rerank) e do teto de pesquisa:
|
|
3
|
-
`site_memory_similarity_threshold` pré-filtra tópicos, o reranker confirma o
|
|
4
|
-
reaproveitamento, e `research_max_iterations` limita o retorno por rodada. Write-only, como
|
|
5
|
-
`router-metrics.jsonl` — nada no daemon lê isto de volta; quem lê é
|
|
6
|
-
`.token-guard/analyze_research.py`.
|
|
7
|
-
|
|
8
|
-
Grava só o que a calibração usa: textos de consulta/tópico truncados e números. Nunca
|
|
9
|
-
snippets nem páginas, que são conteúdo de terceiros e não ajudam a decidir o valor.
|
|
10
|
-
"""
|
|
11
|
-
import json
|
|
12
|
-
import os
|
|
13
|
-
import paths
|
|
14
|
-
import threading
|
|
15
|
-
import time
|
|
16
|
-
|
|
17
|
-
import atomic_io
|
|
18
|
-
|
|
19
|
-
METRICS_PATH = os.path.join(paths.DATA_DIR, "research-metrics.jsonl")
|
|
20
|
-
METRICS_MAX_BYTES = 2 * 1024 * 1024
|
|
21
|
-
MAX_TEXT_CHARS = 200
|
|
22
|
-
MAX_PAIRS = 10
|
|
23
|
-
|
|
24
|
-
# O daemon é multi-thread (fan-out síncrono e jobs de pesquisa em pools próprios): sem
|
|
25
|
-
# lock, dois appends grandes podem se intercalar na mesma linha.
|
|
26
|
-
_LOCK = threading.Lock()
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _short(text):
|
|
30
|
-
text = str(text or "")
|
|
31
|
-
if len(text) > MAX_TEXT_CHARS:
|
|
32
|
-
return text[:MAX_TEXT_CHARS] + "..."
|
|
33
|
-
return text
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _append(record):
|
|
37
|
-
record = {"ts": time.time(), **record}
|
|
38
|
-
try:
|
|
39
|
-
line = json.dumps(record, ensure_ascii=False) + "\n"
|
|
40
|
-
with _LOCK:
|
|
41
|
-
os.makedirs(os.path.dirname(METRICS_PATH), exist_ok=True)
|
|
42
|
-
atomic_io.rotate(METRICS_PATH, METRICS_MAX_BYTES)
|
|
43
|
-
with open(METRICS_PATH, "a", encoding="utf-8") as f:
|
|
44
|
-
f.write(line)
|
|
45
|
-
except (OSError, TypeError, ValueError):
|
|
46
|
-
# Métrica é para calibrar depois: disco cheio ou arquivo travado não pode derrubar
|
|
47
|
-
# uma pesquisa.
|
|
48
|
-
pass
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def log_site_memory(query, threshold, scored_topics, recalled, rerank_scores=None, rerank_threshold=None):
|
|
52
|
-
"""Uma correlação da memória do dia. `scored_topics`: `[(tópico, similaridade)]` de
|
|
53
|
-
todos os tópicos comparados — só os `MAX_PAIRS` mais altos vão pro disco, que são os
|
|
54
|
-
que decidem onde o limiar corta."""
|
|
55
|
-
try:
|
|
56
|
-
ranked = sorted(scored_topics, key=lambda pair: pair[1], reverse=True)
|
|
57
|
-
record = {
|
|
58
|
-
"kind": "site_memory",
|
|
59
|
-
"query": _short(query),
|
|
60
|
-
"threshold": threshold,
|
|
61
|
-
"topics": len(ranked),
|
|
62
|
-
"matched": sum(1 for _, score in ranked if score >= threshold),
|
|
63
|
-
"recalled": recalled,
|
|
64
|
-
"pairs": [
|
|
65
|
-
{
|
|
66
|
-
"topic": _short(topic), "score": round(float(score), 4),
|
|
67
|
-
**({"rerank_score": round(float(rerank_scores[topic]), 4)}
|
|
68
|
-
if rerank_scores is not None and topic in rerank_scores else {}),
|
|
69
|
-
}
|
|
70
|
-
for topic, score in ranked[:MAX_PAIRS]
|
|
71
|
-
],
|
|
72
|
-
}
|
|
73
|
-
if rerank_scores is not None:
|
|
74
|
-
record["rerank_threshold"] = rerank_threshold
|
|
75
|
-
record["rerank_matched"] = sum(
|
|
76
|
-
score >= rerank_threshold for score in rerank_scores.values()
|
|
77
|
-
)
|
|
78
|
-
except Exception:
|
|
79
|
-
return
|
|
80
|
-
_append(record)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def log_research_job(topic, max_iterations, rounds, stopped_by, sources, elapsed_s):
|
|
84
|
-
"""Um job `depth='research'` concluído. `rounds` já traz, por rodada, quanto ela
|
|
85
|
-
acrescentou (`new_urls`, `findings`) — é a curva de retorno marginal que decide o
|
|
86
|
-
teto."""
|
|
87
|
-
try:
|
|
88
|
-
record = {
|
|
89
|
-
"kind": "research",
|
|
90
|
-
"topic": _short(topic),
|
|
91
|
-
"max_iterations": max_iterations,
|
|
92
|
-
"stopped_by": stopped_by,
|
|
93
|
-
"sources": sources,
|
|
94
|
-
"elapsed_s": round(elapsed_s, 1),
|
|
95
|
-
"rounds": [
|
|
96
|
-
{
|
|
97
|
-
"query": _short(r.get("query")),
|
|
98
|
-
"results": r.get("results_count", 0),
|
|
99
|
-
"new_urls": r.get("new_urls", 0),
|
|
100
|
-
"findings": r.get("findings", 0),
|
|
101
|
-
"gaps": r.get("gaps", 0),
|
|
102
|
-
"continue": bool(r.get("continue")),
|
|
103
|
-
"reason": _short(r.get("reason")),
|
|
104
|
-
}
|
|
105
|
-
for r in rounds
|
|
106
|
-
],
|
|
107
|
-
}
|
|
108
|
-
except Exception:
|
|
109
|
-
return
|
|
110
|
-
_append(record)
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Trilha de calibração da memória (cosseno + rerank) e do teto de pesquisa:
|
|
3
|
+
`site_memory_similarity_threshold` pré-filtra tópicos, o reranker confirma o
|
|
4
|
+
reaproveitamento, e `research_max_iterations` limita o retorno por rodada. Write-only, como
|
|
5
|
+
`router-metrics.jsonl` — nada no daemon lê isto de volta; quem lê é
|
|
6
|
+
`.token-guard/analyze_research.py`.
|
|
7
|
+
|
|
8
|
+
Grava só o que a calibração usa: textos de consulta/tópico truncados e números. Nunca
|
|
9
|
+
snippets nem páginas, que são conteúdo de terceiros e não ajudam a decidir o valor.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import paths
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
import atomic_io
|
|
18
|
+
|
|
19
|
+
METRICS_PATH = os.path.join(paths.DATA_DIR, "research-metrics.jsonl")
|
|
20
|
+
METRICS_MAX_BYTES = 2 * 1024 * 1024
|
|
21
|
+
MAX_TEXT_CHARS = 200
|
|
22
|
+
MAX_PAIRS = 10
|
|
23
|
+
|
|
24
|
+
# O daemon é multi-thread (fan-out síncrono e jobs de pesquisa em pools próprios): sem
|
|
25
|
+
# lock, dois appends grandes podem se intercalar na mesma linha.
|
|
26
|
+
_LOCK = threading.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _short(text):
|
|
30
|
+
text = str(text or "")
|
|
31
|
+
if len(text) > MAX_TEXT_CHARS:
|
|
32
|
+
return text[:MAX_TEXT_CHARS] + "..."
|
|
33
|
+
return text
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _append(record):
|
|
37
|
+
record = {"ts": time.time(), **record}
|
|
38
|
+
try:
|
|
39
|
+
line = json.dumps(record, ensure_ascii=False) + "\n"
|
|
40
|
+
with _LOCK:
|
|
41
|
+
os.makedirs(os.path.dirname(METRICS_PATH), exist_ok=True)
|
|
42
|
+
atomic_io.rotate(METRICS_PATH, METRICS_MAX_BYTES)
|
|
43
|
+
with open(METRICS_PATH, "a", encoding="utf-8") as f:
|
|
44
|
+
f.write(line)
|
|
45
|
+
except (OSError, TypeError, ValueError):
|
|
46
|
+
# Métrica é para calibrar depois: disco cheio ou arquivo travado não pode derrubar
|
|
47
|
+
# uma pesquisa.
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def log_site_memory(query, threshold, scored_topics, recalled, rerank_scores=None, rerank_threshold=None):
|
|
52
|
+
"""Uma correlação da memória do dia. `scored_topics`: `[(tópico, similaridade)]` de
|
|
53
|
+
todos os tópicos comparados — só os `MAX_PAIRS` mais altos vão pro disco, que são os
|
|
54
|
+
que decidem onde o limiar corta."""
|
|
55
|
+
try:
|
|
56
|
+
ranked = sorted(scored_topics, key=lambda pair: pair[1], reverse=True)
|
|
57
|
+
record = {
|
|
58
|
+
"kind": "site_memory",
|
|
59
|
+
"query": _short(query),
|
|
60
|
+
"threshold": threshold,
|
|
61
|
+
"topics": len(ranked),
|
|
62
|
+
"matched": sum(1 for _, score in ranked if score >= threshold),
|
|
63
|
+
"recalled": recalled,
|
|
64
|
+
"pairs": [
|
|
65
|
+
{
|
|
66
|
+
"topic": _short(topic), "score": round(float(score), 4),
|
|
67
|
+
**({"rerank_score": round(float(rerank_scores[topic]), 4)}
|
|
68
|
+
if rerank_scores is not None and topic in rerank_scores else {}),
|
|
69
|
+
}
|
|
70
|
+
for topic, score in ranked[:MAX_PAIRS]
|
|
71
|
+
],
|
|
72
|
+
}
|
|
73
|
+
if rerank_scores is not None:
|
|
74
|
+
record["rerank_threshold"] = rerank_threshold
|
|
75
|
+
record["rerank_matched"] = sum(
|
|
76
|
+
score >= rerank_threshold for score in rerank_scores.values()
|
|
77
|
+
)
|
|
78
|
+
except Exception:
|
|
79
|
+
return
|
|
80
|
+
_append(record)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def log_research_job(topic, max_iterations, rounds, stopped_by, sources, elapsed_s):
|
|
84
|
+
"""Um job `depth='research'` concluído. `rounds` já traz, por rodada, quanto ela
|
|
85
|
+
acrescentou (`new_urls`, `findings`) — é a curva de retorno marginal que decide o
|
|
86
|
+
teto."""
|
|
87
|
+
try:
|
|
88
|
+
record = {
|
|
89
|
+
"kind": "research",
|
|
90
|
+
"topic": _short(topic),
|
|
91
|
+
"max_iterations": max_iterations,
|
|
92
|
+
"stopped_by": stopped_by,
|
|
93
|
+
"sources": sources,
|
|
94
|
+
"elapsed_s": round(elapsed_s, 1),
|
|
95
|
+
"rounds": [
|
|
96
|
+
{
|
|
97
|
+
"query": _short(r.get("query")),
|
|
98
|
+
"results": r.get("results_count", 0),
|
|
99
|
+
"new_urls": r.get("new_urls", 0),
|
|
100
|
+
"findings": r.get("findings", 0),
|
|
101
|
+
"gaps": r.get("gaps", 0),
|
|
102
|
+
"continue": bool(r.get("continue")),
|
|
103
|
+
"reason": _short(r.get("reason")),
|
|
104
|
+
}
|
|
105
|
+
for r in rounds
|
|
106
|
+
],
|
|
107
|
+
}
|
|
108
|
+
except Exception:
|
|
109
|
+
return
|
|
110
|
+
_append(record)
|