hebbrix 2.6.3__tar.gz → 2.8.0__tar.gz
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.
- {hebbrix-2.6.3 → hebbrix-2.8.0}/CHANGELOG.md +17 -1
- {hebbrix-2.6.3/hebbrix.egg-info → hebbrix-2.8.0}/PKG-INFO +60 -5
- {hebbrix-2.6.3 → hebbrix-2.8.0}/README.md +59 -4
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/__init__.py +27 -2
- hebbrix-2.8.0/hebbrix/answers.py +316 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/chat.py +46 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/client.py +17 -2
- hebbrix-2.8.0/hebbrix/models.py +138 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/resources.py +179 -33
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/sync_client.py +59 -34
- {hebbrix-2.6.3 → hebbrix-2.8.0/hebbrix.egg-info}/PKG-INFO +60 -5
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix.egg-info/SOURCES.txt +5 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/pyproject.toml +1 -1
- hebbrix-2.8.0/tests/test_answer_context.py +172 -0
- hebbrix-2.8.0/tests/test_answer_decision.py +385 -0
- hebbrix-2.8.0/tests/test_chat_answer_options.py +57 -0
- hebbrix-2.8.0/tests/test_compact_search_default.py +117 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_customer_developer_experience.py +1 -1
- hebbrix-2.6.3/hebbrix/models.py +0 -55
- {hebbrix-2.6.3 → hebbrix-2.8.0}/LICENSE +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/MANIFEST.in +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/contracts/openapi-routes.json +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/docs/native-experience-operations.md +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/examples/native_reflection_worker.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/_advice.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/exceptions.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/execution.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/experience.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/openai_reflection.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/reflection_worker.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/workflow.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix.egg-info/dependency_links.txt +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix.egg-info/entry_points.txt +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix.egg-info/requires.txt +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix.egg-info/top_level.txt +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/setup.cfg +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_advanced_resources.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_advisor_integrity.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_evidence_loop_resource.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_evidence_safety_boundary.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_execution_permission.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_experience_request.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_experience_workflow.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_followup_helpers.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_memories_resource.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_openai_reflection.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_openapi_parity.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_outcome_advice_resource.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_procedural_resource.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_reflection_worker_cli.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_release_version.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_sync_client.py +0 -0
- {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_workflow_route_contract.py +0 -0
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.8.0 — 2026-11-11 (answer decisions and answer context)
|
|
4
|
+
|
|
5
|
+
- New `hebbrix.should_answer(response)`: decides in code whether a `search_with_proof()` or `reason()` response may be answered from, returning an `AnswerDecision` with `action` (`answer`, `say_unknown`, `abstain`, `verify_authorization`, `ask_user`, `retry` or `say_no_longer_current`), `reason`, the usable `memories` and the `context` rows never to answer from. `history=True` counts superseded and historical versions for questions about the past.
|
|
6
|
+
- New `hebbrix.answer_context(response, decision)`: renders the end user's standing profile (facts with category, confidence and date, topics to avoid, scope, and whether it is still being projected or unavailable) beside the rows the decision allows, each with its date, memory type, subject and support.
|
|
7
|
+
- New `hebbrix.usable_memories(response)` and the response types `AnswerStatus`, `RevisionStatus`, `MemoryRow`, `MemoryStatus` and `ScopeIndexing`.
|
|
8
|
+
- `MemoryClient.search_with_proof()` accepts `include_profile` and `include_superseded`, as the synchronous client and `search()` already did.
|
|
9
|
+
- Docs: `memories.create()` stores verbatim unless `infer=True`, while `POST /v1/memories` extracts by default; examples pass `infer` and `wait_for_index` explicitly.
|
|
10
|
+
|
|
11
|
+
## 2.7.0 — 2026-10-19 (compact responses, entity scope, chat answers)
|
|
12
|
+
|
|
13
|
+
- Behaviour change: `search()` and `search_with_proof()` now request `view="compact"` by default (rows keep content, identity, score, rank, timestamps, `low_confidence` and entity scope; the safety envelope and proof context are unchanged; ranking diagnostics need `debug=True`). Pass `view="full"` for the previous per-row metadata. `search()` returns rows only and no longer requests the unused ProofLoop token unless `include_proof_context=True`.
|
|
14
|
+
- `reason()` now requests `view="compact"` by default: the answer, abstention fields (`decision_abstained`, `abstain_reason`), cited sources and safety envelope are kept and the internal decision diagnostics are omitted. Pass `view="full"` or `debug=True` for the full response.
|
|
15
|
+
- Add `prefer_entity_match` (both search methods) and `include_profile` (`search_with_proof`), sent only when enabled; type `query_entities`, `profile` and `view` on the search envelope.
|
|
16
|
+
- `MemoryChat.send()` and `send_with_context()` accept optional `answer_style` (`"natural"`, the server default, or `"quote"`) and `no_information_reply`; they are sent only when set.
|
|
17
|
+
- Requires the server release of 2026-10-19 for the `/search/reason` compact view; the other options are accepted by current servers.
|
|
18
|
+
|
|
3
19
|
## 2.6.3 — 2026-10-14 (candidate memory_ids)
|
|
4
20
|
|
|
5
21
|
- Candidates may carry optional `memory_ids` (at most 50 ids of 1-255 characters) naming the exact memories that action applies; when the candidate becomes the decision's action, its outcome is credited to those memories.
|
|
@@ -12,7 +28,7 @@
|
|
|
12
28
|
- Add optional compact search/advice, bounded independently committed decision/outcome batches, and explicit caller-confirmation transports.
|
|
13
29
|
- Add bounded chat-learning wait options while preserving the original job receipt.
|
|
14
30
|
- Keep historical confidence scope aliases, ACT-as-advice and protected outcome boundaries intact.
|
|
15
|
-
- Requires matching
|
|
31
|
+
- Requires a matching server release; no benchmark superiority, latency SLA or automatic execution is implied.
|
|
16
32
|
|
|
17
33
|
## 2.6.1 — 2026-10-04 (advisory horizon helpers)
|
|
18
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hebbrix
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.8.0
|
|
4
4
|
Summary: Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs
|
|
5
5
|
Author-email: Hebbrix Team <support@hebbrix.com>
|
|
6
6
|
Maintainer-email: Hebbrix Team <support@hebbrix.com>
|
|
@@ -65,14 +65,14 @@ Dynamic: license-file
|
|
|
65
65
|
|
|
66
66
|
Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
|
|
67
67
|
|
|
68
|
-
The current stable package is **2.
|
|
68
|
+
The current stable package is **2.8.0**. Start with memory storage and search, or
|
|
69
69
|
the three-call outcome-learning workflow below. Advanced evidence and execution
|
|
70
70
|
permission transports are opt-in; installing the SDK never permits an action.
|
|
71
71
|
|
|
72
72
|
## Install
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
|
-
pip install hebbrix==2.
|
|
75
|
+
pip install hebbrix==2.8.0
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
|
|
@@ -92,7 +92,8 @@ async def main():
|
|
|
92
92
|
memory = await client.memories.create(
|
|
93
93
|
collection_id=collection["id"],
|
|
94
94
|
content="Customer prefers concise replies",
|
|
95
|
-
|
|
95
|
+
infer=False, # store verbatim (the SDK default; REST defaults to True)
|
|
96
|
+
wait_for_index=True, # return once it is searchable
|
|
96
97
|
idempotency_key="customer-42-preference-v1",
|
|
97
98
|
)
|
|
98
99
|
results = await client.search(
|
|
@@ -104,6 +105,51 @@ async def main():
|
|
|
104
105
|
asyncio.run(main())
|
|
105
106
|
```
|
|
106
107
|
|
|
108
|
+
> **`memories.create()` and `POST /v1/memories` have different defaults.** The
|
|
109
|
+
> SDK stores the content verbatim as one memory unless you pass `infer=True`;
|
|
110
|
+
> the REST endpoint called without `infer` extracts facts with the server's
|
|
111
|
+
> language model (`infer` defaults to `true` there). Pass `infer` and
|
|
112
|
+
> `wait_for_index` explicitly, as above, so a call reads the same through the
|
|
113
|
+
> SDK and over HTTP. Use `infer=True` (or `/v1/memories/process`) for
|
|
114
|
+
> conversations; `infer=False` for a fact you already have in final form.
|
|
115
|
+
|
|
116
|
+
## Decide before you invoke your model
|
|
117
|
+
|
|
118
|
+
A model told to abstain can still answer. Decide in code, and give it only the rows
|
|
119
|
+
that passed. `search()` returns only the rows, so use `search_with_proof()` or
|
|
120
|
+
`reason()`, which keep the fields this reads:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from hebbrix import answer_context, should_answer
|
|
124
|
+
|
|
125
|
+
response = await client.search_with_proof(
|
|
126
|
+
question, user_id="customer-42", include_profile=True
|
|
127
|
+
)
|
|
128
|
+
decision = should_answer(response)
|
|
129
|
+
if not decision:
|
|
130
|
+
# decision.action: ask_user, say_no_longer_current, say_unknown, retry,
|
|
131
|
+
# verify_authorization or abstain. decision.reason says why;
|
|
132
|
+
# decision.context holds the rows, never to answer from.
|
|
133
|
+
return my_reply_for(decision.action)
|
|
134
|
+
# The user's standing profile plus the rows you may answer from, each with its
|
|
135
|
+
# date, memory type, subject and support.
|
|
136
|
+
return await my_llm(question, context=answer_context(response, decision))
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`answer_context(response, decision)` keeps the end user's standing profile
|
|
140
|
+
(`include_profile=True`) beside the rows: preferences such as "keep answers
|
|
141
|
+
short" reach your model even when they did not rank for this message. It lists
|
|
142
|
+
each fact with its category, confidence and date, the topics to avoid, the
|
|
143
|
+
profile's `scope`, and says so when the profile is still being updated
|
|
144
|
+
(`projection.status: "pending"`) or could not be read, so a missing preference
|
|
145
|
+
is not taken as absent. When the decision is not to answer it returns the
|
|
146
|
+
profile alone. `usable_memories(response)` returns just the rows. Pass
|
|
147
|
+
`history=True` for a question about the past, when superseded and historical
|
|
148
|
+
versions should count. `reason()` takes `deadline_ms`; a `timed_out` response is
|
|
149
|
+
an abstention. `verify_authorization` means the returned rule limits the action
|
|
150
|
+
to a group the store cannot place the user in: use the rule, and check the
|
|
151
|
+
permission in your own system.
|
|
152
|
+
|
|
107
153
|
## Learn from outcomes in three calls
|
|
108
154
|
|
|
109
155
|
Use server-side selection when Hebbrix should choose. Read-only advice is for a
|
|
@@ -151,13 +197,21 @@ The canonical `gate` is `ASK`, `REVIEW`, `ACT`, or `BLOCK`. `BLOCK` dominates;
|
|
|
151
197
|
`ACT` is advice, not permission. Apply your own current authorization and human
|
|
152
198
|
approval policy before executing any tool. Do not infer permission from a score.
|
|
153
199
|
|
|
200
|
+
Version 2.7.0 makes search and reason responses compact by default: rows keep
|
|
201
|
+
content, identity, score, timestamps, confidence and entity scope (`applies_to`,
|
|
202
|
+
`entity_match`), and Reason keeps the answer, `decision_abstained`,
|
|
203
|
+
`abstain_reason` and cited sources. Pass `view="full"` or `debug=True` for
|
|
204
|
+
diagnostics. `search_with_proof(include_profile=True)` returns standing profile
|
|
205
|
+
facts and preferences, and `MemoryChat.send(answer_style="quote")` returns the
|
|
206
|
+
stored source text verbatim instead of a plain-language reply.
|
|
207
|
+
|
|
154
208
|
Version 2.6.3 lets each `proofloop.decide` candidate carry `memory_ids`: the exact
|
|
155
209
|
memories that action applies. When that candidate becomes the decision's action,
|
|
156
210
|
its outcome is credited to those memories; a `proof_context` attests what was
|
|
157
211
|
retrieved but never earns per-memory outcome credit by itself.
|
|
158
212
|
|
|
159
213
|
Version 2.6.2 adds compact advice, batch, and explicit confirmation helpers.
|
|
160
|
-
They require matching
|
|
214
|
+
They require a matching server release. Compact views retain scope and safety
|
|
161
215
|
caveats; complete receipts remain on the server.
|
|
162
216
|
|
|
163
217
|
## Durable readiness
|
|
@@ -183,6 +237,7 @@ from hebbrix import IndexingTimeoutError
|
|
|
183
237
|
try:
|
|
184
238
|
created = await client.memories.create(
|
|
185
239
|
content="Customer prefers concise replies",
|
|
240
|
+
infer=False,
|
|
186
241
|
wait_for_index=True,
|
|
187
242
|
idempotency_key="customer-42-preference-v1",
|
|
188
243
|
index_timeout=5,
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
|
|
4
4
|
|
|
5
|
-
The current stable package is **2.
|
|
5
|
+
The current stable package is **2.8.0**. Start with memory storage and search, or
|
|
6
6
|
the three-call outcome-learning workflow below. Advanced evidence and execution
|
|
7
7
|
permission transports are opt-in; installing the SDK never permits an action.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
pip install hebbrix==2.
|
|
12
|
+
pip install hebbrix==2.8.0
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
|
|
@@ -29,7 +29,8 @@ async def main():
|
|
|
29
29
|
memory = await client.memories.create(
|
|
30
30
|
collection_id=collection["id"],
|
|
31
31
|
content="Customer prefers concise replies",
|
|
32
|
-
|
|
32
|
+
infer=False, # store verbatim (the SDK default; REST defaults to True)
|
|
33
|
+
wait_for_index=True, # return once it is searchable
|
|
33
34
|
idempotency_key="customer-42-preference-v1",
|
|
34
35
|
)
|
|
35
36
|
results = await client.search(
|
|
@@ -41,6 +42,51 @@ async def main():
|
|
|
41
42
|
asyncio.run(main())
|
|
42
43
|
```
|
|
43
44
|
|
|
45
|
+
> **`memories.create()` and `POST /v1/memories` have different defaults.** The
|
|
46
|
+
> SDK stores the content verbatim as one memory unless you pass `infer=True`;
|
|
47
|
+
> the REST endpoint called without `infer` extracts facts with the server's
|
|
48
|
+
> language model (`infer` defaults to `true` there). Pass `infer` and
|
|
49
|
+
> `wait_for_index` explicitly, as above, so a call reads the same through the
|
|
50
|
+
> SDK and over HTTP. Use `infer=True` (or `/v1/memories/process`) for
|
|
51
|
+
> conversations; `infer=False` for a fact you already have in final form.
|
|
52
|
+
|
|
53
|
+
## Decide before you invoke your model
|
|
54
|
+
|
|
55
|
+
A model told to abstain can still answer. Decide in code, and give it only the rows
|
|
56
|
+
that passed. `search()` returns only the rows, so use `search_with_proof()` or
|
|
57
|
+
`reason()`, which keep the fields this reads:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from hebbrix import answer_context, should_answer
|
|
61
|
+
|
|
62
|
+
response = await client.search_with_proof(
|
|
63
|
+
question, user_id="customer-42", include_profile=True
|
|
64
|
+
)
|
|
65
|
+
decision = should_answer(response)
|
|
66
|
+
if not decision:
|
|
67
|
+
# decision.action: ask_user, say_no_longer_current, say_unknown, retry,
|
|
68
|
+
# verify_authorization or abstain. decision.reason says why;
|
|
69
|
+
# decision.context holds the rows, never to answer from.
|
|
70
|
+
return my_reply_for(decision.action)
|
|
71
|
+
# The user's standing profile plus the rows you may answer from, each with its
|
|
72
|
+
# date, memory type, subject and support.
|
|
73
|
+
return await my_llm(question, context=answer_context(response, decision))
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`answer_context(response, decision)` keeps the end user's standing profile
|
|
77
|
+
(`include_profile=True`) beside the rows: preferences such as "keep answers
|
|
78
|
+
short" reach your model even when they did not rank for this message. It lists
|
|
79
|
+
each fact with its category, confidence and date, the topics to avoid, the
|
|
80
|
+
profile's `scope`, and says so when the profile is still being updated
|
|
81
|
+
(`projection.status: "pending"`) or could not be read, so a missing preference
|
|
82
|
+
is not taken as absent. When the decision is not to answer it returns the
|
|
83
|
+
profile alone. `usable_memories(response)` returns just the rows. Pass
|
|
84
|
+
`history=True` for a question about the past, when superseded and historical
|
|
85
|
+
versions should count. `reason()` takes `deadline_ms`; a `timed_out` response is
|
|
86
|
+
an abstention. `verify_authorization` means the returned rule limits the action
|
|
87
|
+
to a group the store cannot place the user in: use the rule, and check the
|
|
88
|
+
permission in your own system.
|
|
89
|
+
|
|
44
90
|
## Learn from outcomes in three calls
|
|
45
91
|
|
|
46
92
|
Use server-side selection when Hebbrix should choose. Read-only advice is for a
|
|
@@ -88,13 +134,21 @@ The canonical `gate` is `ASK`, `REVIEW`, `ACT`, or `BLOCK`. `BLOCK` dominates;
|
|
|
88
134
|
`ACT` is advice, not permission. Apply your own current authorization and human
|
|
89
135
|
approval policy before executing any tool. Do not infer permission from a score.
|
|
90
136
|
|
|
137
|
+
Version 2.7.0 makes search and reason responses compact by default: rows keep
|
|
138
|
+
content, identity, score, timestamps, confidence and entity scope (`applies_to`,
|
|
139
|
+
`entity_match`), and Reason keeps the answer, `decision_abstained`,
|
|
140
|
+
`abstain_reason` and cited sources. Pass `view="full"` or `debug=True` for
|
|
141
|
+
diagnostics. `search_with_proof(include_profile=True)` returns standing profile
|
|
142
|
+
facts and preferences, and `MemoryChat.send(answer_style="quote")` returns the
|
|
143
|
+
stored source text verbatim instead of a plain-language reply.
|
|
144
|
+
|
|
91
145
|
Version 2.6.3 lets each `proofloop.decide` candidate carry `memory_ids`: the exact
|
|
92
146
|
memories that action applies. When that candidate becomes the decision's action,
|
|
93
147
|
its outcome is credited to those memories; a `proof_context` attests what was
|
|
94
148
|
retrieved but never earns per-memory outcome credit by itself.
|
|
95
149
|
|
|
96
150
|
Version 2.6.2 adds compact advice, batch, and explicit confirmation helpers.
|
|
97
|
-
They require matching
|
|
151
|
+
They require a matching server release. Compact views retain scope and safety
|
|
98
152
|
caveats; complete receipts remain on the server.
|
|
99
153
|
|
|
100
154
|
## Durable readiness
|
|
@@ -120,6 +174,7 @@ from hebbrix import IndexingTimeoutError
|
|
|
120
174
|
try:
|
|
121
175
|
created = await client.memories.create(
|
|
122
176
|
content="Customer prefers concise replies",
|
|
177
|
+
infer=False,
|
|
123
178
|
wait_for_index=True,
|
|
124
179
|
idempotency_key="customer-42-preference-v1",
|
|
125
180
|
index_timeout=5,
|
|
@@ -5,10 +5,16 @@ production OpenAPI and ``GET /v1/users/me/capabilities``. The experimental
|
|
|
5
5
|
World Model is intentionally absent from this release.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
__version__ = "2.
|
|
8
|
+
__version__ = "2.8.0"
|
|
9
9
|
__author__ = "Hebbrix Team"
|
|
10
10
|
__license__ = "MIT"
|
|
11
11
|
|
|
12
|
+
from hebbrix.answers import (
|
|
13
|
+
AnswerDecision,
|
|
14
|
+
answer_context,
|
|
15
|
+
should_answer,
|
|
16
|
+
usable_memories,
|
|
17
|
+
)
|
|
12
18
|
from hebbrix.chat import MemoryChat
|
|
13
19
|
from hebbrix.client import MemoryClient
|
|
14
20
|
from hebbrix.exceptions import (
|
|
@@ -21,7 +27,17 @@ from hebbrix.exceptions import (
|
|
|
21
27
|
ServerError,
|
|
22
28
|
ValidationError,
|
|
23
29
|
)
|
|
24
|
-
from hebbrix.models import
|
|
30
|
+
from hebbrix.models import (
|
|
31
|
+
ActionAdviceReceipt,
|
|
32
|
+
AnswerStatus,
|
|
33
|
+
EvidenceClaim,
|
|
34
|
+
GroundingReceipt,
|
|
35
|
+
MemoryRow,
|
|
36
|
+
MemoryStatus,
|
|
37
|
+
RevisionStatus,
|
|
38
|
+
ScopeIndexing,
|
|
39
|
+
SearchSafetyEnvelope,
|
|
40
|
+
)
|
|
25
41
|
from hebbrix.sync_client import SyncMemoryClient
|
|
26
42
|
|
|
27
43
|
__all__ = [
|
|
@@ -40,4 +56,13 @@ __all__ = [
|
|
|
40
56
|
"EvidenceClaim",
|
|
41
57
|
"SearchSafetyEnvelope",
|
|
42
58
|
"ActionAdviceReceipt",
|
|
59
|
+
"AnswerDecision",
|
|
60
|
+
"AnswerStatus",
|
|
61
|
+
"MemoryRow",
|
|
62
|
+
"MemoryStatus",
|
|
63
|
+
"RevisionStatus",
|
|
64
|
+
"ScopeIndexing",
|
|
65
|
+
"answer_context",
|
|
66
|
+
"should_answer",
|
|
67
|
+
"usable_memories",
|
|
43
68
|
]
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Decide in code what a search or Reason response lets you do.
|
|
2
|
+
|
|
3
|
+
A model that is told "abstain_recommended: true" can still answer. Call
|
|
4
|
+
``should_answer`` before invoking the model and give it only
|
|
5
|
+
``decision.memories``: when the decision is not to answer, the model is never
|
|
6
|
+
called, so it cannot ignore the flag.
|
|
7
|
+
|
|
8
|
+
response = await client.search_with_proof(
|
|
9
|
+
question, user_id=user_id, include_profile=True
|
|
10
|
+
)
|
|
11
|
+
decision = should_answer(response)
|
|
12
|
+
if not decision:
|
|
13
|
+
return reply_for(decision.action) # your own fixed reply per action
|
|
14
|
+
return await my_llm(question, context=answer_context(response, decision))
|
|
15
|
+
|
|
16
|
+
``answer_context`` renders the end user's standing profile beside those rows,
|
|
17
|
+
so a preference that did not rank for this message still reaches the model.
|
|
18
|
+
|
|
19
|
+
``search()`` returns only the rows, without the fields this needs; use
|
|
20
|
+
``search_with_proof()`` or ``reason()``. The decision is derived from the
|
|
21
|
+
response alone: nothing here calls the API, and it fails closed on a response it
|
|
22
|
+
does not understand.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
ANSWER_STATUSES = (
|
|
29
|
+
"answerable",
|
|
30
|
+
"conflicting",
|
|
31
|
+
"superseded",
|
|
32
|
+
"unverified",
|
|
33
|
+
"indexing",
|
|
34
|
+
"no_relevant_memory",
|
|
35
|
+
)
|
|
36
|
+
REVISION_STATUSES = ("current", "superseded", "conflicting", "historical")
|
|
37
|
+
|
|
38
|
+
# What to do instead of answering, for each ``answer_status``.
|
|
39
|
+
ACTIONS = {
|
|
40
|
+
"answerable": "answer",
|
|
41
|
+
"conflicting": "ask_user",
|
|
42
|
+
"superseded": "say_no_longer_current",
|
|
43
|
+
"unverified": "abstain",
|
|
44
|
+
"indexing": "retry",
|
|
45
|
+
"no_relevant_memory": "say_unknown",
|
|
46
|
+
}
|
|
47
|
+
# A permission question answered by a rule that restricts the act to a group no
|
|
48
|
+
# memory ties the asker to (``confidence_reason`` "authorization_not_verifiable"):
|
|
49
|
+
# the rule is in ``context``; check the asker's role in your own system.
|
|
50
|
+
AUTHORIZATION_NOT_VERIFIABLE = "authorization_not_verifiable"
|
|
51
|
+
VERIFY_AUTHORIZATION = "verify_authorization"
|
|
52
|
+
# Set by the SDK on a response whose rows it kept although it is degraded: not a
|
|
53
|
+
# malformed envelope, so it is not a reason to fail closed on its own.
|
|
54
|
+
_PRESERVED = "degraded_evidence_preserved"
|
|
55
|
+
_PAST = frozenset({"superseded", "historical"})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class AnswerDecision:
|
|
60
|
+
"""What a response lets you do; true only when you may answer from it."""
|
|
61
|
+
|
|
62
|
+
answer: bool
|
|
63
|
+
reason: str
|
|
64
|
+
action: str
|
|
65
|
+
# Safe to pass to a model as evidence; empty unless ``answer`` is true.
|
|
66
|
+
memories: List[Dict[str, Any]] = field(default_factory=list)
|
|
67
|
+
# The other rows returned, each with its revision_status and low_confidence:
|
|
68
|
+
# to show or to ask about ("which of these holds?"), never to answer from.
|
|
69
|
+
context: List[Dict[str, Any]] = field(default_factory=list)
|
|
70
|
+
# Reason only: context for a decision it could not verify, with citations.
|
|
71
|
+
cited_context: Optional[str] = None
|
|
72
|
+
|
|
73
|
+
def __bool__(self) -> bool:
|
|
74
|
+
return self.answer
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _rows(response: Mapping[str, Any]) -> Tuple[List[Dict[str, Any]], bool]:
|
|
78
|
+
"""The response's rows, and whether they are search rows (which always say
|
|
79
|
+
``low_confidence``) rather than Reason's cited sources."""
|
|
80
|
+
|
|
81
|
+
results = response.get("results")
|
|
82
|
+
if isinstance(results, list):
|
|
83
|
+
return [row for row in results if isinstance(row, dict)], True
|
|
84
|
+
sources = response.get("sources")
|
|
85
|
+
if isinstance(sources, list):
|
|
86
|
+
return [row for row in sources if isinstance(row, dict)], False
|
|
87
|
+
return [], False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_evidence(row: Mapping[str, Any], *, search_row: bool) -> bool:
|
|
91
|
+
"""A current row established as evidence. A row that does not say so is not."""
|
|
92
|
+
|
|
93
|
+
if row.get("revision_status") != "current" or row.get("is_current") is False:
|
|
94
|
+
return False
|
|
95
|
+
confidence = row.get("low_confidence")
|
|
96
|
+
return confidence is False if search_row else confidence is not True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_history(row: Mapping[str, Any], *, search_row: bool) -> bool:
|
|
100
|
+
"""A row a question about the past may read: an earlier version, labelled as
|
|
101
|
+
one (the server flags those low_confidence for the present), or evidence."""
|
|
102
|
+
|
|
103
|
+
return row.get("revision_status") in _PAST or _is_evidence(
|
|
104
|
+
row, search_row=search_row
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def should_answer(
|
|
109
|
+
response: Optional[Mapping[str, Any]], *, history: bool = False
|
|
110
|
+
) -> AnswerDecision:
|
|
111
|
+
"""Whether you may answer from ``response`` (a ``search_with_proof`` or
|
|
112
|
+
``reason`` result), the rows to answer from, and what to do when you may not.
|
|
113
|
+
|
|
114
|
+
``history=True`` is for a question about the past that your application
|
|
115
|
+
recognises ("Where did I live before?"): superseded and historical rows then
|
|
116
|
+
count, whatever the response says of the present. Keep each row's
|
|
117
|
+
``revision_status`` and ``stated_at`` in what you pass to the model, so it can
|
|
118
|
+
tell the versions apart. Conflicting rows never count.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
if not isinstance(response, Mapping) or "answer_status" not in response:
|
|
122
|
+
return AnswerDecision(False, "unsupported_response", "abstain")
|
|
123
|
+
status = response.get("answer_status")
|
|
124
|
+
safety = response.get("sdk_safety_reason")
|
|
125
|
+
if status not in ANSWER_STATUSES or (safety and safety != _PRESERVED):
|
|
126
|
+
return AnswerDecision(False, "malformed_response", "abstain")
|
|
127
|
+
rows, search_rows = _rows(response)
|
|
128
|
+
cited = response.get("cited_context")
|
|
129
|
+
cited = cited if isinstance(cited, str) and cited else None
|
|
130
|
+
|
|
131
|
+
def declined(reason: str, action: str) -> AnswerDecision:
|
|
132
|
+
return AnswerDecision(False, reason, action, [], rows, cited)
|
|
133
|
+
|
|
134
|
+
if status == "conflicting":
|
|
135
|
+
return declined(status, ACTIONS[status])
|
|
136
|
+
if response.get("confidence_reason") == AUTHORIZATION_NOT_VERIFIABLE:
|
|
137
|
+
return declined(AUTHORIZATION_NOT_VERIFIABLE, VERIFY_AUTHORIZATION)
|
|
138
|
+
if history:
|
|
139
|
+
usable = [row for row in rows if _is_history(row, search_row=search_rows)]
|
|
140
|
+
if usable:
|
|
141
|
+
others = [row for row in rows if row not in usable]
|
|
142
|
+
return AnswerDecision(True, "history", "answer", usable, others, cited)
|
|
143
|
+
if status != "answerable":
|
|
144
|
+
return declined(status, ACTIONS[status])
|
|
145
|
+
for flag in ("no_match", "abstain_recommended", "decision_abstained"):
|
|
146
|
+
if response.get(flag) is True:
|
|
147
|
+
return declined(flag, "abstain")
|
|
148
|
+
usable = [row for row in rows if _is_evidence(row, search_row=search_rows)]
|
|
149
|
+
if not usable:
|
|
150
|
+
return declined("no_usable_memories", "abstain")
|
|
151
|
+
others = [row for row in rows if row not in usable]
|
|
152
|
+
return AnswerDecision(True, status, "answer", usable, others, cited)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def usable_memories(
|
|
156
|
+
response: Optional[Mapping[str, Any]], *, history: bool = False
|
|
157
|
+
) -> List[Dict[str, Any]]:
|
|
158
|
+
"""The rows of ``response`` safe to pass to a model as evidence; empty when
|
|
159
|
+
``should_answer`` says not to answer."""
|
|
160
|
+
|
|
161
|
+
return should_answer(response, history=history).memories
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _day(value: Any) -> Optional[str]:
|
|
165
|
+
"""The calendar date of an ISO timestamp (the API's dates are ISO 8601)."""
|
|
166
|
+
|
|
167
|
+
return str(value)[:10] if isinstance(value, str) and value else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _count(value: Any) -> int:
|
|
171
|
+
try:
|
|
172
|
+
return int(float(value or 0))
|
|
173
|
+
except (TypeError, ValueError):
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _profile_lines(profile: Any) -> List[str]:
|
|
178
|
+
if not isinstance(profile, Mapping):
|
|
179
|
+
return []
|
|
180
|
+
if profile.get("status") != "ok":
|
|
181
|
+
# Unreadable is not empty: a preference may exist that was not returned.
|
|
182
|
+
return ["Standing profile of this user: unavailable for this request."]
|
|
183
|
+
facts = [
|
|
184
|
+
fact
|
|
185
|
+
for fact in profile.get("facts") or []
|
|
186
|
+
if isinstance(fact, Mapping) and fact.get("value") not in (None, "")
|
|
187
|
+
]
|
|
188
|
+
avoid = [t for t in profile.get("avoid_topics") or [] if isinstance(t, str) and t]
|
|
189
|
+
projection = profile.get("projection")
|
|
190
|
+
projection = projection if isinstance(projection, Mapping) else {}
|
|
191
|
+
pending = projection.get("status") == "pending"
|
|
192
|
+
if not facts and not avoid and not pending:
|
|
193
|
+
return []
|
|
194
|
+
scope = profile.get("scope")
|
|
195
|
+
scope = (
|
|
196
|
+
scope
|
|
197
|
+
if isinstance(scope, Mapping)
|
|
198
|
+
else {"end_user_id": profile.get("end_user_id")}
|
|
199
|
+
)
|
|
200
|
+
details = [f"{key} {value}" for key, value in scope.items() if value]
|
|
201
|
+
if _day(profile.get("as_of")):
|
|
202
|
+
details.append(f"as of {_day(profile.get('as_of'))}")
|
|
203
|
+
if projection.get("status"):
|
|
204
|
+
details.append(f"projection {projection['status']}")
|
|
205
|
+
header = "Standing profile of this user (applies to every reply"
|
|
206
|
+
lines = [header + ("; " + "; ".join(details) if details else "") + "):"]
|
|
207
|
+
for fact in facts:
|
|
208
|
+
name = " / ".join(str(fact[k]) for k in ("category", "key") if fact.get(k))
|
|
209
|
+
meta = []
|
|
210
|
+
if isinstance(fact.get("confidence"), (int, float)):
|
|
211
|
+
meta.append(f"confidence {float(fact['confidence']):.2f}")
|
|
212
|
+
if _day(fact.get("updated_at")):
|
|
213
|
+
meta.append(f"updated {_day(fact.get('updated_at'))}")
|
|
214
|
+
text = f"{name}: {fact['value']}" if name else str(fact["value"])
|
|
215
|
+
lines.append(f"- {text}" + (f" ({', '.join(meta)})" if meta else ""))
|
|
216
|
+
if avoid:
|
|
217
|
+
lines.append("- avoid topics: " + ", ".join(avoid))
|
|
218
|
+
if pending:
|
|
219
|
+
count = projection.get("pending_writes")
|
|
220
|
+
writes = (
|
|
221
|
+
f"{'at least ' if projection.get('capped') else ''}{count} recent writes"
|
|
222
|
+
if isinstance(count, int) and count > 0
|
|
223
|
+
else "recent writes"
|
|
224
|
+
)
|
|
225
|
+
lines.append(
|
|
226
|
+
f"- still being updated: {writes} not yet reflected; a fact here may be "
|
|
227
|
+
"missing or about to change."
|
|
228
|
+
)
|
|
229
|
+
return lines
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _memory_details(row: Mapping[str, Any]) -> List[str]:
|
|
233
|
+
metadata = row.get("metadata") if isinstance(row.get("metadata"), Mapping) else {}
|
|
234
|
+
details = []
|
|
235
|
+
memory_type = metadata.get("memory_type") or row.get("memory_type")
|
|
236
|
+
if memory_type:
|
|
237
|
+
details.append(str(memory_type))
|
|
238
|
+
if _day(row.get("stated_at")):
|
|
239
|
+
details.append(f"stated {_day(row.get('stated_at'))}")
|
|
240
|
+
elif _day(row.get("created_at")):
|
|
241
|
+
details.append(f"saved {_day(row.get('created_at'))}")
|
|
242
|
+
if _day(row.get("valid_until")):
|
|
243
|
+
details.append(f"valid until {_day(row.get('valid_until'))}")
|
|
244
|
+
if row.get("subject"):
|
|
245
|
+
details.append(f"about {row['subject']}")
|
|
246
|
+
applies_to = row.get("applies_to")
|
|
247
|
+
names = (
|
|
248
|
+
[n for n in applies_to if isinstance(n, str) and n]
|
|
249
|
+
if isinstance(applies_to, list)
|
|
250
|
+
else []
|
|
251
|
+
)
|
|
252
|
+
if names:
|
|
253
|
+
details.append("names " + ", ".join(names))
|
|
254
|
+
if row.get("revision_status"):
|
|
255
|
+
details.append(str(row["revision_status"]))
|
|
256
|
+
grounding = row.get("grounding")
|
|
257
|
+
if not isinstance(grounding, Mapping):
|
|
258
|
+
grounding = metadata.get("grounding")
|
|
259
|
+
if isinstance(grounding, Mapping):
|
|
260
|
+
support = grounding.get("support_basis") or grounding.get("status")
|
|
261
|
+
if support:
|
|
262
|
+
details.append(f"support {support}")
|
|
263
|
+
outcomes = metadata.get("outcome_evidence")
|
|
264
|
+
if isinstance(outcomes, Mapping) and (
|
|
265
|
+
outcomes.get("successes") or outcomes.get("failures")
|
|
266
|
+
):
|
|
267
|
+
details.append(
|
|
268
|
+
f"outcomes {_count(outcomes.get('successes'))} worked, "
|
|
269
|
+
f"{_count(outcomes.get('failures'))} failed"
|
|
270
|
+
)
|
|
271
|
+
return details
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def answer_context(
|
|
275
|
+
response: Optional[Mapping[str, Any]],
|
|
276
|
+
decision: Optional[AnswerDecision] = None,
|
|
277
|
+
*,
|
|
278
|
+
history: bool = False,
|
|
279
|
+
) -> str:
|
|
280
|
+
"""The text to give your own model with the user's message: the end user's
|
|
281
|
+
standing profile and the memories ``should_answer`` lets you answer from.
|
|
282
|
+
|
|
283
|
+
Pass a ``search_with_proof(..., include_profile=True)`` or ``reason()``
|
|
284
|
+
response, and the decision you already made on it (computed here when
|
|
285
|
+
omitted). The profile section lists each standing fact with its category,
|
|
286
|
+
confidence and date, the topics to avoid, the profile's scope, and whether
|
|
287
|
+
it is still being projected from recent writes; an unavailable profile is
|
|
288
|
+
said to be unavailable, never left out as if empty. The memories section
|
|
289
|
+
lists only ``decision.memories``, numbered for citation, each with its
|
|
290
|
+
memory type, date, whom it is about, the names it states, its revision
|
|
291
|
+
status and its support. When the decision is not to answer, only the
|
|
292
|
+
profile is returned: standing preferences still shape a reply such as
|
|
293
|
+
"I don't know that yet". Returns "" when there is nothing to add.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
if decision is None:
|
|
297
|
+
decision = should_answer(response, history=history)
|
|
298
|
+
sections = []
|
|
299
|
+
profile = _profile_lines(
|
|
300
|
+
response.get("profile") if isinstance(response, Mapping) else None
|
|
301
|
+
)
|
|
302
|
+
if profile:
|
|
303
|
+
sections.append("\n".join(profile))
|
|
304
|
+
if decision.answer and decision.memories:
|
|
305
|
+
heading = (
|
|
306
|
+
"Memories to answer from (earlier versions are labelled):"
|
|
307
|
+
if decision.reason == "history"
|
|
308
|
+
else "Memories to answer from (current, supported for this message):"
|
|
309
|
+
)
|
|
310
|
+
lines = [heading]
|
|
311
|
+
for index, row in enumerate(decision.memories, start=1):
|
|
312
|
+
details = _memory_details(row)
|
|
313
|
+
lines.append(f"[{index}]" + (f" ({'; '.join(details)})" if details else ""))
|
|
314
|
+
lines.append(str(row.get("content") or ""))
|
|
315
|
+
sections.append("\n".join(lines))
|
|
316
|
+
return "\n\n".join(sections)
|