Brain4Agent 0.0.1__py3-none-any.whl
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.
- brain4agent/__init__.py +38 -0
- brain4agent/__main__.py +8 -0
- brain4agent/cli.py +402 -0
- brain4agent/evidence.py +320 -0
- brain4agent/extract.py +230 -0
- brain4agent/graph.py +100 -0
- brain4agent/judge.py +160 -0
- brain4agent/llm.py +144 -0
- brain4agent/mcp_server.py +437 -0
- brain4agent/read.py +135 -0
- brain4agent/simhash.py +90 -0
- brain4agent/store.py +1190 -0
- brain4agent/tokenize.py +430 -0
- brain4agent-0.0.1.dist-info/METADATA +702 -0
- brain4agent-0.0.1.dist-info/RECORD +19 -0
- brain4agent-0.0.1.dist-info/WHEEL +5 -0
- brain4agent-0.0.1.dist-info/entry_points.txt +2 -0
- brain4agent-0.0.1.dist-info/licenses/LICENSE +202 -0
- brain4agent-0.0.1.dist-info/top_level.txt +1 -0
brain4agent/evidence.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Evidence for the caller to judge, instead of a verdict computed here.
|
|
2
|
+
|
|
3
|
+
Every cheap abstention signal was measured and all of them failed (see
|
|
4
|
+
docs/decisions.md D-008, D-012). At 95% of answerable queries kept, refusal
|
|
5
|
+
tops out around 10% for BM25 score, IDF coverage or unseen-word ratio, and 21%
|
|
6
|
+
for a logistic regression over thirteen features. A model reaches 89-100%, but
|
|
7
|
+
the caller is an agent that already has one, and paying for a second inference
|
|
8
|
+
to answer "is this relevant" is duplicate spend on the critical path.
|
|
9
|
+
|
|
10
|
+
So this module computes no verdict. It retrieves and reports what it can check
|
|
11
|
+
for free, including the one signal that correlates with an unanswerable
|
|
12
|
+
question: **a term the question treats as central that the corpus never
|
|
13
|
+
contains.** The caller decides what to do with that.
|
|
14
|
+
|
|
15
|
+
``missing_terms`` is the useful field, and it is a list of words rather than a
|
|
16
|
+
score on purpose: a caller can show it, log it, or ignore it, and a human can
|
|
17
|
+
read it without knowing what a BM25 score of 13.4 means.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import math
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Protocol
|
|
25
|
+
|
|
26
|
+
from brain4agent.tokenize import load_jieba, word_tokens
|
|
27
|
+
|
|
28
|
+
# Question terms that are structural rather than about the answer. Reporting
|
|
29
|
+
# "什么" as missing would be true of every question and useless.
|
|
30
|
+
_INTERROGATIVE = frozenset(
|
|
31
|
+
{
|
|
32
|
+
"什么",
|
|
33
|
+
"怎么",
|
|
34
|
+
"怎样",
|
|
35
|
+
"哪个",
|
|
36
|
+
"哪些",
|
|
37
|
+
"哪里",
|
|
38
|
+
"多少",
|
|
39
|
+
"几次",
|
|
40
|
+
"多久",
|
|
41
|
+
"为什么",
|
|
42
|
+
"是否",
|
|
43
|
+
"有没有",
|
|
44
|
+
"如何",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_FILLER = frozenset("的了是在和与就都而及或吧呢吗啊呀哦嗯我你他她它这那一个们")
|
|
49
|
+
|
|
50
|
+
# Part-of-speech tags worth reporting as missing.
|
|
51
|
+
#
|
|
52
|
+
# Without this filter the top-1 keep rate was 0.325 -- 27 false refusals out of
|
|
53
|
+
# 40 answerable questions -- because "直接", "应该", "不是", "哪几个" and "最终"
|
|
54
|
+
# are in plenty of *questions* and in no *corpus*. They are the grammar of
|
|
55
|
+
# asking, not the content of an answer. Only nominals and things that look like
|
|
56
|
+
# names can plausibly be the thing a question is missing.
|
|
57
|
+
_CONTENT_POS = frozenset(
|
|
58
|
+
{
|
|
59
|
+
"n", # 名词
|
|
60
|
+
"nr", # 人名
|
|
61
|
+
"ns", # 地名
|
|
62
|
+
"nt", # 机构团体
|
|
63
|
+
"nz", # 其他专名
|
|
64
|
+
"ng",
|
|
65
|
+
"nl",
|
|
66
|
+
"eng", # 英文
|
|
67
|
+
"vn", # 动词性名词:测试、查询
|
|
68
|
+
"an", # 形容词性名词
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _MemoryLike(Protocol):
|
|
74
|
+
id: str
|
|
75
|
+
session_id: str
|
|
76
|
+
content: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Evidence:
|
|
81
|
+
"""One retrieved session, with the checks a caller can act on.
|
|
82
|
+
|
|
83
|
+
``coverage`` is the share of the question's IDF-weighted terms present in
|
|
84
|
+
this session. ``missing_terms`` is what *this session* does not contain --
|
|
85
|
+
which explains why it is ranked where it is, but is not by itself evidence
|
|
86
|
+
that the memory is absent. A session missing 用户 is normal; the corpus
|
|
87
|
+
knowing 用户 somewhere else means the concept is not unknown.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
memory: _MemoryLike
|
|
91
|
+
score: float
|
|
92
|
+
coverage: float
|
|
93
|
+
missing_terms: list[str] = field(default_factory=list)
|
|
94
|
+
# Memories whose text is close enough to this one that they may be saying the
|
|
95
|
+
# same thing. Reported, not resolved: the caller has a model and can read both
|
|
96
|
+
# and decide. Silently merging would be irreversible and this store has no
|
|
97
|
+
# standing to make that call.
|
|
98
|
+
possible_duplicates: list[str] = field(default_factory=list)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def session_id(self) -> str:
|
|
102
|
+
return self.memory.session_id
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True)
|
|
106
|
+
class SearchResult:
|
|
107
|
+
"""What the memory system hands back.
|
|
108
|
+
|
|
109
|
+
``corpus_missing`` is the strong signal: terms the question treats as
|
|
110
|
+
central that appear nowhere in the entire stored corpus. A session
|
|
111
|
+
missing them is irrelevant; the brain missing them is an answer.
|
|
112
|
+
|
|
113
|
+
Keeping this separate from ``Evidence.missing_terms`` matters. Conflating
|
|
114
|
+
the two produced 16 false refusals out of 40, because 用户 and 记忆 are
|
|
115
|
+
absent from most individual sessions and present in the corpus.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
query: str
|
|
119
|
+
evidence: list[Evidence]
|
|
120
|
+
corpus_terms: int
|
|
121
|
+
sessions_searched: int
|
|
122
|
+
corpus_missing: list[str] = field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def empty(self) -> bool:
|
|
126
|
+
return not self.evidence
|
|
127
|
+
|
|
128
|
+
def best(self) -> Evidence | None:
|
|
129
|
+
return self.evidence[0] if self.evidence else None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, slots=True)
|
|
133
|
+
class JudgedResult:
|
|
134
|
+
"""A search plus a hosted judge's opinion, with the distinction kept.
|
|
135
|
+
|
|
136
|
+
``verdict`` is one of:
|
|
137
|
+
|
|
138
|
+
- ``found`` -- the judge accepted a session; ``found`` returns it
|
|
139
|
+
- ``not_found`` -- every checked session was rejected
|
|
140
|
+
- ``uncertain`` -- the judge failed for at least one; the answer is unknown
|
|
141
|
+
|
|
142
|
+
The third state exists because a judge that cannot be reached is not the
|
|
143
|
+
same as a memory that does not exist. A caller that treats them the same
|
|
144
|
+
will tell a user "I never recorded this" during an outage.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
result: SearchResult
|
|
148
|
+
kept: list[Evidence]
|
|
149
|
+
verdict: str
|
|
150
|
+
checks: int
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def found(self) -> Evidence | None:
|
|
154
|
+
return self.kept[0] if self.kept else None
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def certainty(self) -> str:
|
|
158
|
+
return self.verdict
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _is_entity_like(term: str) -> bool:
|
|
162
|
+
"""Fallback heuristic when jieba is absent.
|
|
163
|
+
|
|
164
|
+
No dictionary means no POS tags, so length and filler content are all there
|
|
165
|
+
is to go on. Noisier than the jieba path, and documented as such rather than
|
|
166
|
+
quietly degraded.
|
|
167
|
+
"""
|
|
168
|
+
return len(term) > 1 and term not in _INTERROGATIVE and not all(ch in _FILLER for ch in term)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def content_words(text: str) -> list[str]:
|
|
172
|
+
"""Nominals and names from the question, in order.
|
|
173
|
+
|
|
174
|
+
Uses jieba's POS tagger when available. A long digit-bearing token is kept
|
|
175
|
+
even though it tags as ``m``: an IP address or a version number is exactly
|
|
176
|
+
the kind of thing whose absence is worth reporting.
|
|
177
|
+
"""
|
|
178
|
+
jieba = load_jieba()
|
|
179
|
+
if jieba is None:
|
|
180
|
+
return [t for t in word_tokens(text) if _is_entity_like(t)]
|
|
181
|
+
|
|
182
|
+
import jieba.posseg as pseg
|
|
183
|
+
|
|
184
|
+
out: list[str] = []
|
|
185
|
+
for word, flag in pseg.cut(text):
|
|
186
|
+
w = word.strip().lower()
|
|
187
|
+
if not w or w.isspace():
|
|
188
|
+
continue
|
|
189
|
+
long_number = flag == "m" and len(w) >= 3 and any(ch.isdigit() for ch in w)
|
|
190
|
+
if flag in _CONTENT_POS or long_number:
|
|
191
|
+
out.append(w)
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Backwards-compatible alias: the function was private before the graph
|
|
196
|
+
# needed it from another module.
|
|
197
|
+
_content_words = content_words
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def question_terms(query: str) -> list[str]:
|
|
201
|
+
"""Content terms of the question, longest first.
|
|
202
|
+
|
|
203
|
+
Longest first because the more specific term is the more informative
|
|
204
|
+
absence: for 数据库 what matters is that no session contains it, not that
|
|
205
|
+
数据 appears somewhere.
|
|
206
|
+
"""
|
|
207
|
+
terms = [t for t in content_words(query) if t not in _INTERROGATIVE and t not in _FILLER]
|
|
208
|
+
seen: set[str] = set()
|
|
209
|
+
unique = [t for t in terms if not (t in seen or seen.add(t))]
|
|
210
|
+
return sorted(unique, key=len, reverse=True)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def build_evidence(
|
|
214
|
+
*,
|
|
215
|
+
query: str,
|
|
216
|
+
hits: list[tuple[str, float]],
|
|
217
|
+
by_session: dict[str, list[_MemoryLike]],
|
|
218
|
+
session_text: dict[str, str],
|
|
219
|
+
id_to_session: dict[str, str],
|
|
220
|
+
sessions_searched: int,
|
|
221
|
+
top_k: int,
|
|
222
|
+
) -> SearchResult:
|
|
223
|
+
"""Turn ranked memory ids into ranked session evidence.
|
|
224
|
+
|
|
225
|
+
Presence is checked by substring, not by token set. For the question this
|
|
226
|
+
answers -- does the corpus contain the word 数据库 -- substring matching is
|
|
227
|
+
both exact and cheap, and it avoids tokenising the whole corpus on every
|
|
228
|
+
search. Tokenising for presence would also be wrong in the other direction:
|
|
229
|
+
a session containing 数据 and 存量 separately is not a session about
|
|
230
|
+
数据库, but a bag of bigrams says it is.
|
|
231
|
+
"""
|
|
232
|
+
q_terms = question_terms(query)
|
|
233
|
+
|
|
234
|
+
# Computed before the empty-hits early return. "Nothing matched" and "the
|
|
235
|
+
# corpus has never contained 数据库" are different answers, and the second is
|
|
236
|
+
# what explains the first -- dropping it exactly when nothing matched threw
|
|
237
|
+
# away the only useful part of an empty result.
|
|
238
|
+
#
|
|
239
|
+
# Checked once against the joined corpus, not per session: 用户 missing from
|
|
240
|
+
# one session says nothing, 用户 missing from every session says everything.
|
|
241
|
+
texts = list(session_text.values())
|
|
242
|
+
joined = "\n".join(texts)
|
|
243
|
+
corpus_missing = [t for t in q_terms if t not in joined]
|
|
244
|
+
|
|
245
|
+
if not hits:
|
|
246
|
+
return SearchResult(
|
|
247
|
+
query=query,
|
|
248
|
+
evidence=[],
|
|
249
|
+
corpus_terms=0,
|
|
250
|
+
sessions_searched=sessions_searched,
|
|
251
|
+
corpus_missing=corpus_missing,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
n_sessions = len(by_session) or 1
|
|
255
|
+
|
|
256
|
+
# Memoised. idf(term) is called once per query term for the weight total and
|
|
257
|
+
# again for every evidence row's coverage, so the same term was rescanned up
|
|
258
|
+
# to top_k+1 times over the whole corpus. Measured: build_evidence was 10.9 ms
|
|
259
|
+
# of a 13.8 ms query, and almost all of it was re-walking texts looking for
|
|
260
|
+
# words it had already looked for.
|
|
261
|
+
_df_cache: dict[str, int] = {}
|
|
262
|
+
|
|
263
|
+
def idf(term: str) -> float:
|
|
264
|
+
d = _df_cache.get(term)
|
|
265
|
+
if d is None:
|
|
266
|
+
d = sum(1 for t in texts if term in t)
|
|
267
|
+
_df_cache[term] = d
|
|
268
|
+
return math.log(1.0 + (n_sessions - d + 0.5) / (d + 0.5))
|
|
269
|
+
|
|
270
|
+
total_weight = sum(idf(t) for t in q_terms) or 1.0
|
|
271
|
+
|
|
272
|
+
evidence: list[Evidence] = []
|
|
273
|
+
seen: set[str] = set()
|
|
274
|
+
for memory_id, score in hits:
|
|
275
|
+
sid = id_to_session.get(memory_id)
|
|
276
|
+
if sid is None or sid in seen:
|
|
277
|
+
continue
|
|
278
|
+
seen.add(sid)
|
|
279
|
+
|
|
280
|
+
text = session_text.get(sid, "")
|
|
281
|
+
present = [t for t in q_terms if t in text]
|
|
282
|
+
missing = [t for t in q_terms if t not in text]
|
|
283
|
+
covered = sum(idf(t) for t in present)
|
|
284
|
+
|
|
285
|
+
first = by_session[sid][0]
|
|
286
|
+
evidence.append(
|
|
287
|
+
Evidence(
|
|
288
|
+
memory=first,
|
|
289
|
+
score=score,
|
|
290
|
+
coverage=round(covered / total_weight, 4),
|
|
291
|
+
missing_terms=missing,
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
if len(evidence) >= top_k:
|
|
295
|
+
break
|
|
296
|
+
|
|
297
|
+
corpus_terms = sum(1 for t in q_terms if any(t in x for x in texts))
|
|
298
|
+
return SearchResult(
|
|
299
|
+
query=query,
|
|
300
|
+
evidence=evidence,
|
|
301
|
+
corpus_terms=corpus_terms,
|
|
302
|
+
sessions_searched=sessions_searched,
|
|
303
|
+
corpus_missing=corpus_missing,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def describe(result: SearchResult) -> str:
|
|
308
|
+
"""Human-readable summary of a search."""
|
|
309
|
+
if result.empty:
|
|
310
|
+
line = f"no evidence for {result.query!r}"
|
|
311
|
+
if result.corpus_missing:
|
|
312
|
+
line += f"\n corpus has no: {', '.join(result.corpus_missing)}"
|
|
313
|
+
return line
|
|
314
|
+
lines = [f"{len(result.evidence)} sessions for {result.query!r}"]
|
|
315
|
+
if result.corpus_missing:
|
|
316
|
+
lines.append(f" corpus has no: {', '.join(result.corpus_missing)}")
|
|
317
|
+
for e in result.evidence:
|
|
318
|
+
missing = f" session lacks: {', '.join(e.missing_terms)}" if e.missing_terms else ""
|
|
319
|
+
lines.append(f" [{e.score:5.2f} cov={e.coverage:.2f}] {e.session_id}{missing}")
|
|
320
|
+
return "\n".join(lines)
|
brain4agent/extract.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Turn a conversation turn into durable memories.
|
|
2
|
+
|
|
3
|
+
This is the piece that makes the store a memory system rather than a log.
|
|
4
|
+
Storing every turn verbatim is what produced the noise in D-020: a hundred
|
|
5
|
+
exchanges about the same subsystem, none of which can be told apart. Extracting
|
|
6
|
+
one self-contained fact per thing worth keeping is how a real memory layer
|
|
7
|
+
avoids that — and it is exactly what neural-memory does with fibers.
|
|
8
|
+
|
|
9
|
+
Optional by design. Without a key, ``Extractor.from_env()`` returns None and the
|
|
10
|
+
store falls back to keeping raw turns, which still works.
|
|
11
|
+
|
|
12
|
+
What "durable" means here, because it is the whole judgement call:
|
|
13
|
+
|
|
14
|
+
- a fact about how the system is
|
|
15
|
+
- a decision and its reason
|
|
16
|
+
- an error and its cause
|
|
17
|
+
- a preference about how things should be done
|
|
18
|
+
|
|
19
|
+
Not durable: acknowledgements, restatements of the question, plans that were
|
|
20
|
+
never executed, and anything a reader would find obvious from the code.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
from brain4agent.llm import LLM
|
|
30
|
+
|
|
31
|
+
KINDS = frozenset({"fact", "decision", "error", "preference", "context"})
|
|
32
|
+
|
|
33
|
+
MAX_INPUT_CHARS = 6000
|
|
34
|
+
MAX_CONTENT_CHARS = 500
|
|
35
|
+
MAX_ENTITIES = 10
|
|
36
|
+
DEFAULT_MAX_MEMORIES = 5
|
|
37
|
+
|
|
38
|
+
SYSTEM = """You extract durable memories from one exchange in a working conversation.
|
|
39
|
+
|
|
40
|
+
A durable memory is something a colleague would need weeks later: a fact about
|
|
41
|
+
how the system is, a decision and its reason, an error and its cause, or a
|
|
42
|
+
stated preference.
|
|
43
|
+
|
|
44
|
+
Rules:
|
|
45
|
+
- One memory per entry. Each must be self-contained: a reader with no context
|
|
46
|
+
must understand it. Replace pronouns and deictic references ("it", "that file",
|
|
47
|
+
"上次那个") with what they actually name.
|
|
48
|
+
- **Keep the question's vocabulary.** A future search will use the words of the
|
|
49
|
+
person asking, not yours. If the question asks "哪个文件哪一行", the memory
|
|
50
|
+
must contain 文件 and 行. Rewriting it as only "extraction/keywords.py 第 443 行"
|
|
51
|
+
makes it unfindable by the very question it answers.
|
|
52
|
+
- Keep specifics verbatim: file names, line numbers, addresses, versions, commit
|
|
53
|
+
hashes, error messages.
|
|
54
|
+
- Drop acknowledgements, pure pleasantries, and plans never executed.
|
|
55
|
+
- If the exchange has nothing durable, return an empty list. Returning nothing
|
|
56
|
+
is a correct and common answer.
|
|
57
|
+
|
|
58
|
+
Output strict JSON and nothing else:
|
|
59
|
+
{"memories":[{"kind":"fact","content":"...","entities":["..."],"importance":0.7}]}
|
|
60
|
+
|
|
61
|
+
kind is one of: fact, decision, error, preference, context.
|
|
62
|
+
importance is 0.0-1.0: how likely a future question depends on this entry."""
|
|
63
|
+
|
|
64
|
+
USER = """Exchange:
|
|
65
|
+
---
|
|
66
|
+
{exchange}
|
|
67
|
+
---"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class ExtractedMemory:
|
|
72
|
+
"""One durable item distilled from a turn."""
|
|
73
|
+
|
|
74
|
+
kind: str
|
|
75
|
+
content: str
|
|
76
|
+
entities: tuple[str, ...] = ()
|
|
77
|
+
importance: float = 0.5
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class ExtractionResult:
|
|
82
|
+
"""Outcome of one extraction.
|
|
83
|
+
|
|
84
|
+
The distinction between an empty result and a failed one decides what the
|
|
85
|
+
store does: empty means "nothing durable here, drop the turn", failed means
|
|
86
|
+
"the model was unavailable, keep the raw text". Collapsing them would
|
|
87
|
+
either lose data on an outage or keep chit-chat when the model says it is
|
|
88
|
+
chit-chat.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
memories: tuple[ExtractedMemory, ...] = ()
|
|
92
|
+
ok: bool = True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Extractor:
|
|
96
|
+
"""Distils a turn into zero or more :class:`ExtractedMemory`.
|
|
97
|
+
|
|
98
|
+
Every public method returns a list, never raises for model problems. A
|
|
99
|
+
failed extraction must degrade to "store the raw turn", which the caller
|
|
100
|
+
decides; it must not take the write down with it.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, llm: LLM, *, max_memories: int = DEFAULT_MAX_MEMORIES) -> None:
|
|
104
|
+
self.llm = llm
|
|
105
|
+
self.max_memories = max_memories
|
|
106
|
+
self.calls = 0
|
|
107
|
+
self.failures = 0
|
|
108
|
+
self.empty = 0
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_env(cls) -> Extractor | None:
|
|
112
|
+
llm = LLM.from_env()
|
|
113
|
+
return cls(llm) if llm is not None else None
|
|
114
|
+
|
|
115
|
+
def extract(
|
|
116
|
+
self,
|
|
117
|
+
text: str,
|
|
118
|
+
*,
|
|
119
|
+
question: str | None = None,
|
|
120
|
+
max_memories: int | None = None,
|
|
121
|
+
) -> ExtractionResult:
|
|
122
|
+
"""Distil one turn, or one question/answer pair.
|
|
123
|
+
|
|
124
|
+
Passing ``question`` is strongly preferred when the text is an answer:
|
|
125
|
+
the question carries the words a future search will use, and dropping
|
|
126
|
+
them was measured to halve hit@1 (D-023).
|
|
127
|
+
"""
|
|
128
|
+
body = text.strip()
|
|
129
|
+
if not body:
|
|
130
|
+
return ExtractionResult((), ok=True)
|
|
131
|
+
|
|
132
|
+
if question and question.strip():
|
|
133
|
+
exchange = f"question: {question.strip()}\nanswer: {body}"
|
|
134
|
+
else:
|
|
135
|
+
exchange = body
|
|
136
|
+
|
|
137
|
+
limit = max_memories or self.max_memories
|
|
138
|
+
self.calls += 1
|
|
139
|
+
raw = self.llm.chat(
|
|
140
|
+
SYSTEM, USER.format(exchange=exchange[:MAX_INPUT_CHARS]), max_tokens=1024
|
|
141
|
+
)
|
|
142
|
+
if raw is None:
|
|
143
|
+
self.failures += 1
|
|
144
|
+
return ExtractionResult((), ok=False)
|
|
145
|
+
|
|
146
|
+
memories = parse(raw, limit=limit)
|
|
147
|
+
if not memories:
|
|
148
|
+
# "Nothing durable here" is a legitimate answer, not a failure, and
|
|
149
|
+
# must not count against health.
|
|
150
|
+
self.empty += 1
|
|
151
|
+
return ExtractionResult(tuple(memories), ok=True)
|
|
152
|
+
|
|
153
|
+
def stats(self) -> dict[str, object]:
|
|
154
|
+
return {
|
|
155
|
+
"calls": self.calls,
|
|
156
|
+
"failures": self.failures,
|
|
157
|
+
"empty": self.empty,
|
|
158
|
+
**self.llm.stats(),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse(raw: str, *, limit: int = DEFAULT_MAX_MEMORIES) -> list[ExtractedMemory]:
|
|
163
|
+
"""Parse a model response into memories, dropping anything malformed.
|
|
164
|
+
|
|
165
|
+
Models wrap JSON in prose or fences often enough that a strict
|
|
166
|
+
``json.loads`` on the whole string is not enough. Anything that does not
|
|
167
|
+
survive validation is dropped rather than repaired: a guessed memory is
|
|
168
|
+
worse than a missing one.
|
|
169
|
+
"""
|
|
170
|
+
blob = _find_json(raw)
|
|
171
|
+
if blob is None:
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
items = blob.get("memories")
|
|
175
|
+
if not isinstance(items, list):
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
out: list[ExtractedMemory] = []
|
|
179
|
+
for item in items[:limit]:
|
|
180
|
+
if not isinstance(item, dict):
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
kind = str(item.get("kind", "")).strip().lower()
|
|
184
|
+
if kind not in KINDS:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
content = str(item.get("content", "")).strip()
|
|
188
|
+
if not content:
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
raw_entities = item.get("entities")
|
|
192
|
+
entities: list[str] = []
|
|
193
|
+
if isinstance(raw_entities, list):
|
|
194
|
+
for value in raw_entities:
|
|
195
|
+
text = str(value).strip()
|
|
196
|
+
if text and text not in entities:
|
|
197
|
+
entities.append(text)
|
|
198
|
+
entities = entities[:MAX_ENTITIES]
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
importance = float(item.get("importance", 0.5))
|
|
202
|
+
except (TypeError, ValueError):
|
|
203
|
+
importance = 0.5
|
|
204
|
+
importance = min(1.0, max(0.0, importance))
|
|
205
|
+
|
|
206
|
+
out.append(
|
|
207
|
+
ExtractedMemory(
|
|
208
|
+
kind=kind,
|
|
209
|
+
content=content[:MAX_CONTENT_CHARS],
|
|
210
|
+
entities=tuple(entities),
|
|
211
|
+
importance=importance,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
_FENCE = re.compile(r"^```[a-zA-Z]*\s*|\s*```$")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _find_json(raw: str) -> dict | None:
|
|
221
|
+
text = _FENCE.sub("", raw.strip())
|
|
222
|
+
start = text.find("{")
|
|
223
|
+
end = text.rfind("}")
|
|
224
|
+
if start < 0 or end <= start:
|
|
225
|
+
return None
|
|
226
|
+
try:
|
|
227
|
+
value = json.loads(text[start : end + 1])
|
|
228
|
+
except json.JSONDecodeError:
|
|
229
|
+
return None
|
|
230
|
+
return value if isinstance(value, dict) else None
|
brain4agent/graph.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Entity co-occurrence graph.
|
|
2
|
+
|
|
3
|
+
D-023 left a specific gap. Distilled memories recall well (hit@10 1.0) but rank
|
|
4
|
+
poorly, because every fact is a clean, topic-focused sentence and facts about
|
|
5
|
+
the same subject share most of their vocabulary. IDF falls, and the top result
|
|
6
|
+
becomes a coin flip between memories that are all "about" the question.
|
|
7
|
+
|
|
8
|
+
Word matching cannot fix that, because the information is not in the words — it
|
|
9
|
+
is in which memory is *connected* to the question through what it is about.
|
|
10
|
+
|
|
11
|
+
This module supplies that connection cheaply and without a model:
|
|
12
|
+
|
|
13
|
+
- each memory records its entities (nouns and names, extracted at write time)
|
|
14
|
+
- an entity shared by two memories is an edge between them
|
|
15
|
+
- retrieval expands from the best word-matches along those edges, with decay,
|
|
16
|
+
so a memory that shares a specific entity with a strong hit gets a boost even
|
|
17
|
+
if it does not share the query's exact wording
|
|
18
|
+
|
|
19
|
+
Deliberately not Personalized PageRank. PPR needs a converged power iteration
|
|
20
|
+
over the whole graph per query, and the measured problem here is one hop deep:
|
|
21
|
+
"the fact I want shares an entity with a fact that already matched". One hop
|
|
22
|
+
plus decay is measurable, explainable, and fast. If a two-hop gain ever shows up
|
|
23
|
+
in a benchmark, this is the place to extend.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections import Counter
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
|
|
31
|
+
# An entity appearing in more than this share of memories carries no signal --
|
|
32
|
+
# it is the corpus's background, not a distinction. Keeping it would let every
|
|
33
|
+
# memory reach every other and turn the boost into a constant.
|
|
34
|
+
COMMON_ENTITY_RATIO = 0.25
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class EntityGraph:
|
|
39
|
+
"""Memory <-> entity index with one-hop expansion."""
|
|
40
|
+
|
|
41
|
+
_by_entity: dict[str, set[str]] = field(default_factory=dict)
|
|
42
|
+
_by_memory: dict[str, set[str]] = field(default_factory=dict)
|
|
43
|
+
_n: int = 0
|
|
44
|
+
|
|
45
|
+
def add(self, memory_id: str, entities: list[str] | tuple[str, ...]) -> None:
|
|
46
|
+
if not entities:
|
|
47
|
+
return
|
|
48
|
+
unique = {e for e in entities if e}
|
|
49
|
+
if not unique:
|
|
50
|
+
return
|
|
51
|
+
self._by_memory[memory_id] = unique
|
|
52
|
+
for entity in unique:
|
|
53
|
+
self._by_entity.setdefault(entity, set()).add(memory_id)
|
|
54
|
+
|
|
55
|
+
def finish(self) -> None:
|
|
56
|
+
"""Record corpus size so the common-entity cut-off can be applied."""
|
|
57
|
+
self._n = len(self._by_memory)
|
|
58
|
+
|
|
59
|
+
def _too_common(self, entity: str) -> bool:
|
|
60
|
+
if self._n < 8: # too small to have a meaningful background
|
|
61
|
+
return False
|
|
62
|
+
return len(self._by_entity.get(entity, ())) > max(8, self._n * COMMON_ENTITY_RATIO)
|
|
63
|
+
|
|
64
|
+
def expand(
|
|
65
|
+
self,
|
|
66
|
+
seeds: list[tuple[str, float]],
|
|
67
|
+
*,
|
|
68
|
+
decay: float = 0.35,
|
|
69
|
+
max_seeds: int = 5,
|
|
70
|
+
) -> dict[str, float]:
|
|
71
|
+
"""Boost memories that share a specific entity with a strong hit.
|
|
72
|
+
|
|
73
|
+
``seeds`` is the ranked BM25 output. Only the head of it is expanded:
|
|
74
|
+
propagating from a weak match would spread noise rather than signal.
|
|
75
|
+
|
|
76
|
+
Returns memory_id -> boost, excluding the seeds themselves.
|
|
77
|
+
"""
|
|
78
|
+
boosts: dict[str, float] = {}
|
|
79
|
+
seed_ids = {mid for mid, _ in seeds}
|
|
80
|
+
|
|
81
|
+
for memory_id, score in seeds[:max_seeds]:
|
|
82
|
+
for entity in self._by_memory.get(memory_id, ()):
|
|
83
|
+
if self._too_common(entity):
|
|
84
|
+
continue
|
|
85
|
+
for other in self._by_entity.get(entity, ()):
|
|
86
|
+
if other in seed_ids:
|
|
87
|
+
continue
|
|
88
|
+
candidate = score * decay
|
|
89
|
+
if candidate > boosts.get(other, 0.0):
|
|
90
|
+
boosts[other] = candidate
|
|
91
|
+
return boosts
|
|
92
|
+
|
|
93
|
+
def stats(self) -> dict[str, int]:
|
|
94
|
+
sizes = Counter(len(v) for v in self._by_memory.values())
|
|
95
|
+
return {
|
|
96
|
+
"memories": len(self._by_memory),
|
|
97
|
+
"entities": len(self._by_entity),
|
|
98
|
+
"edges": sum(len(v) - 1 for v in self._by_entity.values() if len(v) > 1),
|
|
99
|
+
"isolated": sizes.get(0, 0) + sum(1 for v in self._by_memory.values() if len(v) == 1),
|
|
100
|
+
}
|