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.
Files changed (53) hide show
  1. {hebbrix-2.6.3 → hebbrix-2.8.0}/CHANGELOG.md +17 -1
  2. {hebbrix-2.6.3/hebbrix.egg-info → hebbrix-2.8.0}/PKG-INFO +60 -5
  3. {hebbrix-2.6.3 → hebbrix-2.8.0}/README.md +59 -4
  4. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/__init__.py +27 -2
  5. hebbrix-2.8.0/hebbrix/answers.py +316 -0
  6. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/chat.py +46 -0
  7. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/client.py +17 -2
  8. hebbrix-2.8.0/hebbrix/models.py +138 -0
  9. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/resources.py +179 -33
  10. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/sync_client.py +59 -34
  11. {hebbrix-2.6.3 → hebbrix-2.8.0/hebbrix.egg-info}/PKG-INFO +60 -5
  12. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix.egg-info/SOURCES.txt +5 -0
  13. {hebbrix-2.6.3 → hebbrix-2.8.0}/pyproject.toml +1 -1
  14. hebbrix-2.8.0/tests/test_answer_context.py +172 -0
  15. hebbrix-2.8.0/tests/test_answer_decision.py +385 -0
  16. hebbrix-2.8.0/tests/test_chat_answer_options.py +57 -0
  17. hebbrix-2.8.0/tests/test_compact_search_default.py +117 -0
  18. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_customer_developer_experience.py +1 -1
  19. hebbrix-2.6.3/hebbrix/models.py +0 -55
  20. {hebbrix-2.6.3 → hebbrix-2.8.0}/LICENSE +0 -0
  21. {hebbrix-2.6.3 → hebbrix-2.8.0}/MANIFEST.in +0 -0
  22. {hebbrix-2.6.3 → hebbrix-2.8.0}/contracts/openapi-routes.json +0 -0
  23. {hebbrix-2.6.3 → hebbrix-2.8.0}/docs/native-experience-operations.md +0 -0
  24. {hebbrix-2.6.3 → hebbrix-2.8.0}/examples/native_reflection_worker.py +0 -0
  25. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/_advice.py +0 -0
  26. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/exceptions.py +0 -0
  27. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/execution.py +0 -0
  28. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/experience.py +0 -0
  29. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/openai_reflection.py +0 -0
  30. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/reflection_worker.py +0 -0
  31. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix/workflow.py +0 -0
  32. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix.egg-info/dependency_links.txt +0 -0
  33. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix.egg-info/entry_points.txt +0 -0
  34. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix.egg-info/requires.txt +0 -0
  35. {hebbrix-2.6.3 → hebbrix-2.8.0}/hebbrix.egg-info/top_level.txt +0 -0
  36. {hebbrix-2.6.3 → hebbrix-2.8.0}/setup.cfg +0 -0
  37. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_advanced_resources.py +0 -0
  38. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_advisor_integrity.py +0 -0
  39. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_evidence_loop_resource.py +0 -0
  40. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_evidence_safety_boundary.py +0 -0
  41. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_execution_permission.py +0 -0
  42. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_experience_request.py +0 -0
  43. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_experience_workflow.py +0 -0
  44. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_followup_helpers.py +0 -0
  45. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_memories_resource.py +0 -0
  46. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_openai_reflection.py +0 -0
  47. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_openapi_parity.py +0 -0
  48. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_outcome_advice_resource.py +0 -0
  49. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_procedural_resource.py +0 -0
  50. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_reflection_worker_cli.py +0 -0
  51. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_release_version.py +0 -0
  52. {hebbrix-2.6.3 → hebbrix-2.8.0}/tests/test_sync_client.py +0 -0
  53. {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 Round9 server routes; no benchmark superiority, latency SLA or automatic execution is implied.
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.6.3
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.6.3**. Start with memory storage and search, or
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.6.3
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
- wait_for_index=True,
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 Round9 server routes. Compact views retain scope and safety
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.6.3**. Start with memory storage and search, or
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.6.3
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
- wait_for_index=True,
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 Round9 server routes. Compact views retain scope and safety
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.6.3"
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 ActionAdviceReceipt, EvidenceClaim, GroundingReceipt, SearchSafetyEnvelope
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)