-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy patheval_recall.py
More file actions
159 lines (132 loc) · 5.98 KB
/
Copy patheval_recall.py
File metadata and controls
159 lines (132 loc) · 5.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
#!/usr/bin/env python3
"""
agent-memory-hub — recall eval harness.
The tool's own thesis, applied to itself: don't *trust* that recall surfaces the right
past context — *measure* it. Runs the real recall path (`memory_client.recall`, hybrid if
EMBED_KEY is set) and scores it with hit@k and MRR.
Two modes:
--auto N (default) Retrieval regression check. Samples N recent sessions, turns each
session's own summary into a query, and checks whether that same session comes
back near the top. It won't tell you recall is "smart", but it *will* scream when
recall is broken (embeddings down, FTS misconfigured, RPC changed) — the silent
failure this project exists to catch.
--gold F Curated check. Reads a JSON file of cases and scores them:
[{"query": "how did we set up backups",
"expect": {"project": "agent-memory-hub", "contains": "pg_dump"}}]
A case hits if a returned session matches `project` and/or has `contains` in its
text (either key optional). See tests/eval/recall_gold.example.json.
Usage:
python3 scripts/eval_recall.py --auto 30
python3 scripts/eval_recall.py --auto 60 --spread # sample across the corpus (published numbers)
python3 scripts/eval_recall.py --auto 30 --project mysite --k 5
python3 scripts/eval_recall.py --gold tests/eval/recall_gold.example.json
Config (env or .env): SUPABASE_URL, SUPABASE_SECRET_KEY, EMBED_KEY (for hybrid recall).
"""
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from memory_client import recall, rest, EK # noqa: E402
DEFAULT_N = 25
DEFAULT_KS = (1, 3, 5)
_COUNT_RE = re.compile(r"\s*\(\d+q/\d+r\)\s*$")
def query_from_summary(summary):
"""Turn a stored summary into a search query: drop the (Nq/Nr) counter and the
'[...] <arc>' tail, keep the leading theme. Returns '' if nothing usable."""
if not summary:
return ""
s = _COUNT_RE.sub("", summary).strip()
s = s.split(" [...] ")[0].strip()
return " ".join(s.split())[:120]
def rank_of(target_id, results):
"""1-based rank of target_id in results (list of dicts with session_id), or None."""
for i, r in enumerate(results):
if r.get("session_id") == target_id:
return i + 1
return None
def metrics(ranks, ks=DEFAULT_KS):
"""From a list of ranks (int or None), compute hit@k for each k and MRR."""
n = len(ranks) or 1
out = {f"hit@{k}": sum(1 for r in ranks if r is not None and r <= k) / n for k in ks}
out["mrr"] = sum((1.0 / r) for r in ranks if r) / n
return out
def sample_sessions(n, project=None, spread=False):
"""spread=False: as N mais recentes (regressao — grita quando o caminho quebra).
spread=True: N espalhadas pelo corpus (ordena por session_id — pseudo-aleatorio
deterministico): representativo e reprodutivel; modo usado pros numeros publicados."""
flt = f"&project=eq.{project}" if project else ""
order = "session_id.asc" if spread else "started_at.desc"
rows = rest(f"sessions?select=session_id,project,summary"
f"&summary=not.is.null&order={order}&limit={n}{flt}")
return [r for r in rows if query_from_summary(r.get("summary"))]
def run_auto(n, project, k, verbose, spread=False):
rows = sample_sessions(n, project, spread)
if not rows:
print("nenhuma sessão com summary para avaliar.", file=sys.stderr)
return 1
ranks = []
for r in rows:
q = query_from_summary(r["summary"])
res = recall(q, project=project, limit=k)
rank = rank_of(r["session_id"], res)
ranks.append(rank)
if verbose:
tag = f"#{rank}" if rank else "miss"
print(f" {tag:>5} {r['session_id'][:8]}… {q[:64]}")
report("auto", len(ranks), metrics(ranks, ks=tuple(sorted({1, 3, k}))), project, k)
return 0
def run_gold(path, project, k, verbose):
with open(path) as f:
cases = json.load(f)
ranks = []
for case in cases:
q = case.get("query", "")
exp = case.get("expect") or {}
res = recall(q, project=exp.get("project") or project, limit=k)
rank = None
for i, row in enumerate(res):
ok_proj = ("project" not in exp) or row.get("project") == exp["project"]
ok_has = ("contains" not in exp) or (exp["contains"].lower() in (row.get("text") or "").lower())
if ok_proj and ok_has:
rank = i + 1
break
ranks.append(rank)
if verbose:
tag = f"#{rank}" if rank else "miss"
print(f" {tag:>5} {q[:64]}")
report("gold", len(ranks), metrics(ranks, ks=tuple(sorted({1, 3, k}))), project, k)
return 0
def report(mode, n, m, project, k):
scope = f" · project={project}" if project else ""
path = "hybrid (semantic+keyword)" if EK else "keyword (no EMBED_KEY)"
print(f"\nagent-memory-hub · recall eval [{mode}] n={n} · k={k} · {path}{scope}")
for key in sorted(m):
if key.startswith("hit@"):
print(f" {key:<8} {m[key]*100:5.1f}%")
print(f" {'mrr':<8} {m['mrr']:.3f}")
def main(argv):
mode, n, project, k, gold, verbose, spread = "auto", DEFAULT_N, None, 5, None, False, False
i = 0
while i < len(argv):
a = argv[i]
if a == "--auto":
mode = "auto"; n = int(argv[i + 1]); i += 2
elif a == "--gold":
mode = "gold"; gold = argv[i + 1]; i += 2
elif a == "--project":
project = argv[i + 1]; i += 2
elif a == "--k":
k = int(argv[i + 1]); i += 2
elif a == "--spread":
spread = True; i += 1
elif a in ("-v", "--verbose"):
verbose = True; i += 1
else:
print(f"arg desconhecido: {a}", file=sys.stderr); return 2
if mode == "gold":
return run_gold(gold, project, k, verbose)
return run_auto(n, project, k, verbose, spread)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))