hebbrix 2.7.0__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.7.0 → hebbrix-2.8.0}/CHANGELOG.md +8 -0
- {hebbrix-2.7.0/hebbrix.egg-info → hebbrix-2.8.0}/PKG-INFO +51 -4
- {hebbrix-2.7.0 → hebbrix-2.8.0}/README.md +50 -3
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/__init__.py +27 -2
- hebbrix-2.8.0/hebbrix/answers.py +316 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/client.py +11 -2
- hebbrix-2.8.0/hebbrix/models.py +138 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/resources.py +28 -3
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/sync_client.py +13 -1
- {hebbrix-2.7.0 → hebbrix-2.8.0/hebbrix.egg-info}/PKG-INFO +51 -4
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix.egg-info/SOURCES.txt +3 -0
- {hebbrix-2.7.0 → 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.7.0 → hebbrix-2.8.0}/tests/test_customer_developer_experience.py +1 -1
- hebbrix-2.7.0/hebbrix/models.py +0 -61
- {hebbrix-2.7.0 → hebbrix-2.8.0}/LICENSE +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/MANIFEST.in +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/contracts/openapi-routes.json +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/docs/native-experience-operations.md +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/examples/native_reflection_worker.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/_advice.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/chat.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/exceptions.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/execution.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/experience.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/openai_reflection.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/reflection_worker.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix/workflow.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix.egg-info/dependency_links.txt +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix.egg-info/entry_points.txt +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix.egg-info/requires.txt +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/hebbrix.egg-info/top_level.txt +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/setup.cfg +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_advanced_resources.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_advisor_integrity.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_chat_answer_options.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_compact_search_default.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_evidence_loop_resource.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_evidence_safety_boundary.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_execution_permission.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_experience_request.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_experience_workflow.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_followup_helpers.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_memories_resource.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_openai_reflection.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_openapi_parity.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_outcome_advice_resource.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_procedural_resource.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_reflection_worker_cli.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_release_version.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_sync_client.py +0 -0
- {hebbrix-2.7.0 → hebbrix-2.8.0}/tests/test_workflow_route_contract.py +0 -0
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 2.7.0 — 2026-10-19 (compact responses, entity scope, chat answers)
|
|
4
12
|
|
|
5
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`.
|
|
@@ -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
|
|
@@ -191,6 +237,7 @@ from hebbrix import IndexingTimeoutError
|
|
|
191
237
|
try:
|
|
192
238
|
created = await client.memories.create(
|
|
193
239
|
content="Customer prefers concise replies",
|
|
240
|
+
infer=False,
|
|
194
241
|
wait_for_index=True,
|
|
195
242
|
idempotency_key="customer-42-preference-v1",
|
|
196
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
|
|
@@ -128,6 +174,7 @@ from hebbrix import IndexingTimeoutError
|
|
|
128
174
|
try:
|
|
129
175
|
created = await client.memories.create(
|
|
130
176
|
content="Customer prefers concise replies",
|
|
177
|
+
infer=False,
|
|
131
178
|
wait_for_index=True,
|
|
132
179
|
idempotency_key="customer-42-preference-v1",
|
|
133
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)
|
|
@@ -93,7 +93,7 @@ class MemoryClient:
|
|
|
93
93
|
"""Get request headers."""
|
|
94
94
|
headers = {
|
|
95
95
|
"Content-Type": "application/json",
|
|
96
|
-
"User-Agent": "hebbrix-python/2.
|
|
96
|
+
"User-Agent": "hebbrix-python/2.8.0",
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
if self.api_key:
|
|
@@ -302,8 +302,12 @@ class MemoryClient:
|
|
|
302
302
|
group_by_source: bool = True,
|
|
303
303
|
debug: bool = False,
|
|
304
304
|
view: Optional[str] = None,
|
|
305
|
+
include_superseded: bool = False,
|
|
306
|
+
include_profile: bool = False,
|
|
305
307
|
) -> Dict[str, Any]:
|
|
306
|
-
"""Search and return
|
|
308
|
+
"""Search and return the response's rows, its abstention fields
|
|
309
|
+
(``answer_status``, ``abstain_recommended``: ``hebbrix.should_answer``
|
|
310
|
+
decides from them) and the automatic ProofLoop context."""
|
|
307
311
|
|
|
308
312
|
return await self.search_resource.search_with_proof(
|
|
309
313
|
query=query,
|
|
@@ -320,6 +324,8 @@ class MemoryClient:
|
|
|
320
324
|
group_by_source=group_by_source,
|
|
321
325
|
debug=debug,
|
|
322
326
|
view=view,
|
|
327
|
+
include_superseded=include_superseded,
|
|
328
|
+
include_profile=include_profile,
|
|
323
329
|
)
|
|
324
330
|
|
|
325
331
|
async def reason(
|
|
@@ -334,6 +340,7 @@ class MemoryClient:
|
|
|
334
340
|
facets: Optional[List[str]] = None,
|
|
335
341
|
view: str = "compact",
|
|
336
342
|
debug: bool = False,
|
|
343
|
+
deadline_ms: Optional[int] = None,
|
|
337
344
|
) -> Dict[str, Any]:
|
|
338
345
|
"""
|
|
339
346
|
Perform reasoning over memories.
|
|
@@ -349,6 +356,7 @@ class MemoryClient:
|
|
|
349
356
|
facets: Optional typed decomposition facets
|
|
350
357
|
view: ``"compact"`` (default) or ``"full"`` diagnostics
|
|
351
358
|
debug: Return the full diagnostic response
|
|
359
|
+
deadline_ms: The response's latency budget, at most the server's
|
|
352
360
|
|
|
353
361
|
Returns:
|
|
354
362
|
Reasoning result with answer and sources
|
|
@@ -364,6 +372,7 @@ class MemoryClient:
|
|
|
364
372
|
facets=facets,
|
|
365
373
|
view=view,
|
|
366
374
|
debug=debug,
|
|
375
|
+
deadline_ms=deadline_ms,
|
|
367
376
|
)
|
|
368
377
|
|
|
369
378
|
async def close(self):
|