needlepath 0.2.0__tar.gz → 0.2.2__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 (30) hide show
  1. {needlepath-0.2.0 → needlepath-0.2.2}/CHANGELOG.md +33 -0
  2. {needlepath-0.2.0 → needlepath-0.2.2}/PKG-INFO +87 -7
  3. {needlepath-0.2.0 → needlepath-0.2.2}/README.md +85 -5
  4. {needlepath-0.2.0 → needlepath-0.2.2}/pyproject.toml +2 -1
  5. {needlepath-0.2.0 → needlepath-0.2.2}/src/needlepath/__init__.py +17 -1
  6. {needlepath-0.2.0 → needlepath-0.2.2}/src/needlepath/client.py +233 -15
  7. needlepath-0.2.2/src/needlepath/integrations/__init__.py +30 -0
  8. needlepath-0.2.2/src/needlepath/integrations/system_one.py +210 -0
  9. {needlepath-0.2.0 → needlepath-0.2.2}/src/needlepath/models.py +210 -4
  10. {needlepath-0.2.0 → needlepath-0.2.2}/src/needlepath/result.py +24 -1
  11. {needlepath-0.2.0 → needlepath-0.2.2}/src/needlepath/version.py +1 -1
  12. {needlepath-0.2.0 → needlepath-0.2.2}/tests/test_async_parity.py +3 -1
  13. {needlepath-0.2.0 → needlepath-0.2.2}/tests/test_fail_open.py +158 -0
  14. {needlepath-0.2.0 → needlepath-0.2.2}/tests/test_live_endpoint.py +7 -5
  15. needlepath-0.2.2/tests/test_r4_contract.py +195 -0
  16. needlepath-0.2.2/tests/test_system_one_adapter.py +337 -0
  17. {needlepath-0.2.0 → needlepath-0.2.2}/tests/test_wire_compatibility.py +91 -4
  18. {needlepath-0.2.0 → needlepath-0.2.2}/.gitignore +0 -0
  19. {needlepath-0.2.0 → needlepath-0.2.2}/LICENSE +0 -0
  20. {needlepath-0.2.0 → needlepath-0.2.2}/src/needlepath/errors.py +0 -0
  21. {needlepath-0.2.0 → needlepath-0.2.2}/src/needlepath/features.py +0 -0
  22. {needlepath-0.2.0 → needlepath-0.2.2}/src/needlepath/py.typed +0 -0
  23. {needlepath-0.2.0 → needlepath-0.2.2}/src/needlepath/testing.py +0 -0
  24. {needlepath-0.2.0 → needlepath-0.2.2}/src/needlepath/transport.py +0 -0
  25. {needlepath-0.2.0 → needlepath-0.2.2}/tests/test_config_and_guards.py +0 -0
  26. {needlepath-0.2.0 → needlepath-0.2.2}/tests/test_no_secret_leak.py +0 -0
  27. {needlepath-0.2.0 → needlepath-0.2.2}/tests/test_retries.py +0 -0
  28. {needlepath-0.2.0 → needlepath-0.2.2}/tests/test_shadow_mode.py +0 -0
  29. {needlepath-0.2.0 → needlepath-0.2.2}/tests/test_simple_shape.py +0 -0
  30. {needlepath-0.2.0 → needlepath-0.2.2}/tests/test_urllib_transport.py +0 -0
@@ -1,3 +1,12 @@
1
+ ## 0.2.2
2
+
3
+ ### Changed
4
+
5
+ - First published build carrying the `sanitized_fallback` and `blocked`
6
+ security-screening outcomes, `security_mode` on requests and the
7
+ `security_action` receipt field (see the API release notes). 0.2.1 predates
8
+ them and classifies a screened fallback as `selection_unsafe`.
9
+
1
10
  # Changelog
2
11
 
3
12
  All notable changes to the `needlepath` Python package are documented here.
@@ -5,6 +14,30 @@ All notable changes to the `needlepath` Python package are documented here.
5
14
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
15
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
16
 
17
+ ## [Unreleased]
18
+
19
+ ### Added
20
+
21
+ - **System One state adapter**: `needlepath.integrations.system_one` reshapes a
22
+ selection into the `{state, questions}` request body that small classifier
23
+ models (TypeSafe's Jev, Laya) accept. `to_system_one_state()` carries every
24
+ record in `response.selected`, in the order returned, with the text
25
+ unmodified; `mode="rendered"` returns `state` as one string instead of an
26
+ array. `questions` travels in the shape it was passed, mapping or sequence,
27
+ so a `{id: question}` mapping never arrives as a list of its keys.
28
+ A `SelectionResult` is accepted only when it `applied`; a passthrough raises
29
+ `NeedlepathConfigError` naming the reason, since its response can carry a
30
+ selection the client declined to use. `selection_receipt()` returns
31
+ `request_id`, `selection_id`, `policy_version`, `tokens_saved` and `outcome`
32
+ as a sidecar dict, prefixed the way `SelectionResult.metadata()` prefixes its
33
+ keys.
34
+
35
+ ## [0.2.1] - 2026-09-03
36
+
37
+ ### Changed
38
+
39
+ - Docs, docstrings and examples pin np-2026-08-r4. No behaviour change.
40
+
8
41
  ## [0.2.0] - 2026-08-07
9
42
 
10
43
  ### Added
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: needlepath
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Framework-free client for the Needlepath hosted context-selection service.
5
5
  Project-URL: Homepage, https://nextmoca.com
6
6
  Author: Next Moca Global, Inc.
@@ -57,7 +57,7 @@ from needlepath import NeedlepathClient, ContextRecord, TaskSpec
57
57
 
58
58
  client = NeedlepathClient(
59
59
  api_key="np_live_...", # or NEEDLEPATH_API_KEY
60
- operating_point="np-2026-07-r2", # required — see "Pin the operating point"
60
+ operating_point="np-2026-08-r4", # required — see "Pin the operating point"
61
61
  )
62
62
 
63
63
  result = client.select(
@@ -80,7 +80,7 @@ original context. That is the whole integration contract.
80
80
  ```python
81
81
  from needlepath import AsyncNeedlepathClient
82
82
 
83
- async with AsyncNeedlepathClient(operating_point="np-2026-07-r2") as client:
83
+ async with AsyncNeedlepathClient(operating_point="np-2026-08-r4") as client:
84
84
  result = await client.select(records=..., task=..., max_context_tokens=4000)
85
85
  ```
86
86
 
@@ -88,6 +88,41 @@ Identical semantics — the decision logic is literally the same functions. Uses
88
88
  `httpx.AsyncClient` when httpx is importable, a worker thread otherwise; it
89
89
  never blocks the event loop either way.
90
90
 
91
+ ### R4 trace and outcome attribution
92
+
93
+ Pin `np-2026-08-r4` and opt into the metadata-only trace when you need to
94
+ inspect record dispositions:
95
+
96
+ ```python
97
+ result = client.select(
98
+ records=records,
99
+ task=TaskSpec(prompt=question),
100
+ max_context_tokens=4000,
101
+ return_trace=True,
102
+ )
103
+
104
+ if result.response and result.response.selection_trace:
105
+ for item in result.response.selection_trace.records:
106
+ print(item.record_id, item.disposition, item.reason)
107
+ ```
108
+
109
+ The hosted response also supplies `result.selection_id`, a server-issued id
110
+ separate from your request id. Report downstream quality after the model call:
111
+
112
+ ```python
113
+ if result.selection_id:
114
+ client.report_outcome(
115
+ result.selection_id,
116
+ task_success=True,
117
+ context_sufficient=True,
118
+ evaluation_source="application",
119
+ )
120
+ ```
121
+
122
+ `report_outcome()` is idempotent and independently retryable. It can raise on a
123
+ callback failure; that never changes `select()` or the context already returned.
124
+ Do not send prompts, context, model output, answers, or free-form notes.
125
+
91
126
  ## Configuration
92
127
 
93
128
  | Argument | Environment | Default |
@@ -130,6 +165,7 @@ if not result.applied:
130
165
  | `disabled` | no | the integration was constructed disabled |
131
166
  | `no_records` | no | nothing to select over; no call was made |
132
167
  | `below_trigger` | no | under the configured trigger; no call was made |
168
+ | `escalated` | no | the engine declined: full context over capacity. **Charged at the full rate**, unlike a free stand-down |
133
169
  | `empty_selection` | no | zero records selected |
134
170
  | `engine_fallback` | no | `fallback_used` — the engine wants the full context |
135
171
  | `selection_error` | no | the response carried a `selection_error` |
@@ -158,7 +194,7 @@ customer absorbs; failing open on "no operating point was pinned" accepts
158
194
  exactly the behaviour drift the rule exists to prevent. It fails at wire-up,
159
195
  never under load.
160
196
 
161
- Labels are immutable: `np-2026-07-r2` means one frozen configuration forever, and
197
+ Labels are immutable: `np-2026-08-r4` means one frozen configuration forever, and
162
198
  a retune mints a new label. `client.operating_points()` lists what the endpoint
163
199
  serves — call it once at wire-up if you want the assertion, never per request.
164
200
 
@@ -176,7 +212,7 @@ that passes at 5.99 MB can still be rejected upstream. Lower
176
212
  ### 4. Shadow mode is a one-line switch
177
213
 
178
214
  ```python
179
- client = NeedlepathClient(operating_point="np-2026-07-r2", shadow=True)
215
+ client = NeedlepathClient(operating_point="np-2026-08-r4", shadow=True)
180
216
  ```
181
217
 
182
218
  The call is made exactly as a live call would be — same body, same billing, same
@@ -222,13 +258,57 @@ carry fragments of your input in an exception message — the Lambda deliberatel
222
258
  declines to log it for that reason — so only its *type* appears here. The full
223
259
  string stays on `result.response.selection_error`.
224
260
 
261
+ ## System One models (TypeSafe Jev, Laya)
262
+
263
+ Small classifier models that take `{state, questions}` and answer the questions
264
+ against the state. `needlepath.integrations.system_one` reshapes a selection
265
+ into that request body:
266
+
267
+ ```python
268
+ from needlepath.integrations.system_one import selection_receipt, to_system_one_state
269
+
270
+ result = client.select(records=records, task=task, max_context_tokens=4000)
271
+ payload = to_system_one_state(
272
+ # Fail open with your own records: a result that did not apply is refused.
273
+ result if result.applied else records,
274
+ questions={"refund": Noul(instructions="Is this a refund request?")},
275
+ )
276
+ # Sidecar metadata, not part of the body.
277
+ receipt = selection_receipt(result)
278
+ ```
279
+
280
+ `payload["state"]` is a JSON array of `{record_id, title, source, text}`, one
281
+ entry per record in `response.selected`, **in the order the service returned
282
+ them, with the text unmodified**. The adapter is transport only: no threshold on
283
+ `score`, no drop for an empty excerpt, no re-sort, no deduplication, and no
284
+ question you did not pass. `mode="rendered"` returns `state` as a single string
285
+ instead, records joined by blank lines under a one-line `[record_id] title`
286
+ header, for a classifier whose state argument is text.
287
+
288
+ `questions` travels in the shape you pass it. `langchain-typesafe` reads a
289
+ mapping of id to typed question (`Noul`, `Choice`, `Score`); a different
290
+ `{state, questions}` service may read a list. Both are copied and sent
291
+ unchanged, so a mapping never arrives as a list of its keys. A bare string is
292
+ refused rather than iterated into single-character questions.
293
+
294
+ `to_system_one_state` accepts a `SelectionResult` only when it `applied`. A
295
+ result that did not apply is a passthrough — your own records are what has to
296
+ be sent — and its `response` can still carry a selection the client itself
297
+ declined to use, so passing one raises `NeedlepathConfigError` naming the
298
+ reason. To reshape that response anyway, for an audit or a shadow report, pass
299
+ `result.response`.
300
+
301
+ `selection_receipt()` returns `request_id`, `selection_id`, `policy_version`,
302
+ `tokens_saved` and `outcome`, prefixed the way `result.metadata()` prefixes its
303
+ keys. Attribution travels beside the request; record text never travels in it.
304
+
225
305
  ## Testing your integration
226
306
 
227
307
  ```python
228
308
  from needlepath.testing import FakeTransport, response_payload, ok, error
229
309
 
230
310
  transport = FakeTransport([error(503), ok(response_payload("np-1"))])
231
- client = NeedlepathClient(operating_point="np-2026-07-r2", transport=transport)
311
+ client = NeedlepathClient(operating_point="np-2026-08-r4", transport=transport)
232
312
  ```
233
313
 
234
314
  `needlepath.testing` is public API, not a private helper. Every rule above is a
@@ -28,7 +28,7 @@ from needlepath import NeedlepathClient, ContextRecord, TaskSpec
28
28
 
29
29
  client = NeedlepathClient(
30
30
  api_key="np_live_...", # or NEEDLEPATH_API_KEY
31
- operating_point="np-2026-07-r2", # required — see "Pin the operating point"
31
+ operating_point="np-2026-08-r4", # required — see "Pin the operating point"
32
32
  )
33
33
 
34
34
  result = client.select(
@@ -51,7 +51,7 @@ original context. That is the whole integration contract.
51
51
  ```python
52
52
  from needlepath import AsyncNeedlepathClient
53
53
 
54
- async with AsyncNeedlepathClient(operating_point="np-2026-07-r2") as client:
54
+ async with AsyncNeedlepathClient(operating_point="np-2026-08-r4") as client:
55
55
  result = await client.select(records=..., task=..., max_context_tokens=4000)
56
56
  ```
57
57
 
@@ -59,6 +59,41 @@ Identical semantics — the decision logic is literally the same functions. Uses
59
59
  `httpx.AsyncClient` when httpx is importable, a worker thread otherwise; it
60
60
  never blocks the event loop either way.
61
61
 
62
+ ### R4 trace and outcome attribution
63
+
64
+ Pin `np-2026-08-r4` and opt into the metadata-only trace when you need to
65
+ inspect record dispositions:
66
+
67
+ ```python
68
+ result = client.select(
69
+ records=records,
70
+ task=TaskSpec(prompt=question),
71
+ max_context_tokens=4000,
72
+ return_trace=True,
73
+ )
74
+
75
+ if result.response and result.response.selection_trace:
76
+ for item in result.response.selection_trace.records:
77
+ print(item.record_id, item.disposition, item.reason)
78
+ ```
79
+
80
+ The hosted response also supplies `result.selection_id`, a server-issued id
81
+ separate from your request id. Report downstream quality after the model call:
82
+
83
+ ```python
84
+ if result.selection_id:
85
+ client.report_outcome(
86
+ result.selection_id,
87
+ task_success=True,
88
+ context_sufficient=True,
89
+ evaluation_source="application",
90
+ )
91
+ ```
92
+
93
+ `report_outcome()` is idempotent and independently retryable. It can raise on a
94
+ callback failure; that never changes `select()` or the context already returned.
95
+ Do not send prompts, context, model output, answers, or free-form notes.
96
+
62
97
  ## Configuration
63
98
 
64
99
  | Argument | Environment | Default |
@@ -101,6 +136,7 @@ if not result.applied:
101
136
  | `disabled` | no | the integration was constructed disabled |
102
137
  | `no_records` | no | nothing to select over; no call was made |
103
138
  | `below_trigger` | no | under the configured trigger; no call was made |
139
+ | `escalated` | no | the engine declined: full context over capacity. **Charged at the full rate**, unlike a free stand-down |
104
140
  | `empty_selection` | no | zero records selected |
105
141
  | `engine_fallback` | no | `fallback_used` — the engine wants the full context |
106
142
  | `selection_error` | no | the response carried a `selection_error` |
@@ -129,7 +165,7 @@ customer absorbs; failing open on "no operating point was pinned" accepts
129
165
  exactly the behaviour drift the rule exists to prevent. It fails at wire-up,
130
166
  never under load.
131
167
 
132
- Labels are immutable: `np-2026-07-r2` means one frozen configuration forever, and
168
+ Labels are immutable: `np-2026-08-r4` means one frozen configuration forever, and
133
169
  a retune mints a new label. `client.operating_points()` lists what the endpoint
134
170
  serves — call it once at wire-up if you want the assertion, never per request.
135
171
 
@@ -147,7 +183,7 @@ that passes at 5.99 MB can still be rejected upstream. Lower
147
183
  ### 4. Shadow mode is a one-line switch
148
184
 
149
185
  ```python
150
- client = NeedlepathClient(operating_point="np-2026-07-r2", shadow=True)
186
+ client = NeedlepathClient(operating_point="np-2026-08-r4", shadow=True)
151
187
  ```
152
188
 
153
189
  The call is made exactly as a live call would be — same body, same billing, same
@@ -193,13 +229,57 @@ carry fragments of your input in an exception message — the Lambda deliberatel
193
229
  declines to log it for that reason — so only its *type* appears here. The full
194
230
  string stays on `result.response.selection_error`.
195
231
 
232
+ ## System One models (TypeSafe Jev, Laya)
233
+
234
+ Small classifier models that take `{state, questions}` and answer the questions
235
+ against the state. `needlepath.integrations.system_one` reshapes a selection
236
+ into that request body:
237
+
238
+ ```python
239
+ from needlepath.integrations.system_one import selection_receipt, to_system_one_state
240
+
241
+ result = client.select(records=records, task=task, max_context_tokens=4000)
242
+ payload = to_system_one_state(
243
+ # Fail open with your own records: a result that did not apply is refused.
244
+ result if result.applied else records,
245
+ questions={"refund": Noul(instructions="Is this a refund request?")},
246
+ )
247
+ # Sidecar metadata, not part of the body.
248
+ receipt = selection_receipt(result)
249
+ ```
250
+
251
+ `payload["state"]` is a JSON array of `{record_id, title, source, text}`, one
252
+ entry per record in `response.selected`, **in the order the service returned
253
+ them, with the text unmodified**. The adapter is transport only: no threshold on
254
+ `score`, no drop for an empty excerpt, no re-sort, no deduplication, and no
255
+ question you did not pass. `mode="rendered"` returns `state` as a single string
256
+ instead, records joined by blank lines under a one-line `[record_id] title`
257
+ header, for a classifier whose state argument is text.
258
+
259
+ `questions` travels in the shape you pass it. `langchain-typesafe` reads a
260
+ mapping of id to typed question (`Noul`, `Choice`, `Score`); a different
261
+ `{state, questions}` service may read a list. Both are copied and sent
262
+ unchanged, so a mapping never arrives as a list of its keys. A bare string is
263
+ refused rather than iterated into single-character questions.
264
+
265
+ `to_system_one_state` accepts a `SelectionResult` only when it `applied`. A
266
+ result that did not apply is a passthrough — your own records are what has to
267
+ be sent — and its `response` can still carry a selection the client itself
268
+ declined to use, so passing one raises `NeedlepathConfigError` naming the
269
+ reason. To reshape that response anyway, for an audit or a shadow report, pass
270
+ `result.response`.
271
+
272
+ `selection_receipt()` returns `request_id`, `selection_id`, `policy_version`,
273
+ `tokens_saved` and `outcome`, prefixed the way `result.metadata()` prefixes its
274
+ keys. Attribution travels beside the request; record text never travels in it.
275
+
196
276
  ## Testing your integration
197
277
 
198
278
  ```python
199
279
  from needlepath.testing import FakeTransport, response_payload, ok, error
200
280
 
201
281
  transport = FakeTransport([error(503), ok(response_payload("np-1"))])
202
- client = NeedlepathClient(operating_point="np-2026-07-r2", transport=transport)
282
+ client = NeedlepathClient(operating_point="np-2026-08-r4", transport=transport)
203
283
  ```
204
284
 
205
285
  `needlepath.testing` is public API, not a private helper. Every rule above is a
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "needlepath"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Framework-free client for the Needlepath hosted context-selection service."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -52,6 +52,7 @@ packages = ["src/needlepath"]
52
52
  [tool.pytest.ini_options]
53
53
  testpaths = ["tests"]
54
54
  asyncio_mode = "auto"
55
+ asyncio_default_fixture_loop_scope = "function"
55
56
  filterwarnings = ["error"]
56
57
 
57
58
  [tool.ruff]
@@ -2,7 +2,7 @@
2
2
 
3
3
  from needlepath import NeedlepathClient, ContextRecord, TaskSpec
4
4
 
5
- client = NeedlepathClient(operating_point="np-2026-07-r2") # key from env
5
+ client = NeedlepathClient(operating_point="np-2026-08-r4") # key from env
6
6
  result = client.select(
7
7
  records=[ContextRecord(text=tool_output, kind="tool_result", id="t1")],
8
8
  task=TaskSpec(prompt=user_question),
@@ -33,10 +33,13 @@ from .client import (
33
33
  HEALTH_PATH,
34
34
  MAX_REQUEST_BYTES,
35
35
  OPERATING_POINTS_PATH,
36
+ OUTCOME_RETRY_STATUSES,
37
+ OUTCOMES_PATH,
36
38
  RETRY_STATUSES,
37
39
  SELECT_PATH,
38
40
  AsyncNeedlepathClient,
39
41
  NeedlepathClient,
42
+ build_outcome_report,
40
43
  build_request,
41
44
  classify,
42
45
  encode_body,
@@ -61,9 +64,14 @@ from .models import (
61
64
  ContextRequest,
62
65
  ContextResponse,
63
66
  GateSummary,
67
+ OutcomeReport,
68
+ OutcomeResponse,
64
69
  RecordKind,
65
70
  SafetySummary,
71
+ SecurityMode,
66
72
  SelectedRecord,
73
+ SelectionTrace,
74
+ SelectionTraceRecord,
67
75
  TaskSpec,
68
76
  )
69
77
  from .result import BENIGN_REASONS, Reason, SelectionResult, SelectionStats
@@ -93,7 +101,12 @@ __all__ = [
93
101
  "SelectedRecord",
94
102
  "SafetySummary",
95
103
  "GateSummary",
104
+ "SelectionTrace",
105
+ "SelectionTraceRecord",
106
+ "OutcomeReport",
107
+ "OutcomeResponse",
96
108
  "RecordKind",
109
+ "SecurityMode",
97
110
  "REQUIRED_RESPONSE_FIELDS",
98
111
  # results
99
112
  "SelectionResult",
@@ -118,6 +131,7 @@ __all__ = [
118
131
  "default_async_transport",
119
132
  # helpers
120
133
  "build_request",
134
+ "build_outcome_report",
121
135
  "encode_body",
122
136
  "classify",
123
137
  "validate_response_payload",
@@ -125,12 +139,14 @@ __all__ = [
125
139
  # constants
126
140
  "DEFAULT_BASE_URL",
127
141
  "SELECT_PATH",
142
+ "OUTCOMES_PATH",
128
143
  "HEALTH_PATH",
129
144
  "OPERATING_POINTS_PATH",
130
145
  "MAX_REQUEST_BYTES",
131
146
  "DEFAULT_TIMEOUT_S",
132
147
  "DEFAULT_MAX_RETRIES",
133
148
  "RETRY_STATUSES",
149
+ "OUTCOME_RETRY_STATUSES",
134
150
  # feature flags
135
151
  "WIRE_SHADOW_MODE",
136
152
  "wire_shadow_mode_enabled",