nessyapi-sdk 0.2.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.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: nessyapi-sdk
3
+ Version: 0.2.0
4
+ Summary: Python SDK for NessyAPI — Clinical Decision Support API
5
+ Author-email: "HealthyNess.cz" <dev@healthyness.cz>
6
+ License: MIT
7
+ Project-URL: Homepage, https://healthyness.cz
8
+ Project-URL: Documentation, https://github.com/jachymvrtiskaHN/NessyAPI/tree/master/sdk
9
+ Project-URL: Repository, https://github.com/jachymvrtiskaHN/NessyAPI
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Healthcare Industry
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: httpx>=0.27
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
27
+
28
+ # NessyAPI Python SDK
29
+
30
+ Python SDK for [NessyAPI](https://healthyness.cz) — Clinical Decision Support API.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install nessyapi-sdk
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ```python
41
+ from nessyapi_sdk import NessyClient
42
+
43
+ with NessyClient(api_key="nsy_live_...") as client:
44
+ # Run a full assessment
45
+ results = client.run_assessment("headache", age=35, sex="male")
46
+
47
+ print(f"Triage: {results.triage_level}")
48
+ for dx in results.differentials:
49
+ print(f" {dx.diagnosis}: {dx.probability:.0%}")
50
+ ```
51
+
52
+ ## Async
53
+
54
+ ```python
55
+ from nessyapi_sdk import AsyncNessyClient
56
+
57
+ async with AsyncNessyClient(api_key="nsy_live_...") as client:
58
+ session = await client.create_session("chest_pain", age=55, sex="male")
59
+ result = await client.answer(session.session_id, "q1", raw_text="pressure pain")
60
+ final = await client.finalize(session.session_id)
61
+ ```
62
+
63
+ ## Features
64
+
65
+ - Sync and async clients
66
+ - Typed response models (dataclasses)
67
+ - Automatic retry with exponential backoff (429, 5xx)
68
+ - Patient profile management
69
+ - Webhook signature verification
70
+
71
+ ## Links
72
+
73
+ - [API Reference](https://github.com/jachymvrtiskaHN/NessyAPI/blob/master/docs/api-reference.md)
74
+ - [SDK Guide](https://github.com/jachymvrtiskaHN/NessyAPI/blob/master/docs/sdk-guide.md)
@@ -0,0 +1,47 @@
1
+ # NessyAPI Python SDK
2
+
3
+ Python SDK for [NessyAPI](https://healthyness.cz) — Clinical Decision Support API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install nessyapi-sdk
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from nessyapi_sdk import NessyClient
15
+
16
+ with NessyClient(api_key="nsy_live_...") as client:
17
+ # Run a full assessment
18
+ results = client.run_assessment("headache", age=35, sex="male")
19
+
20
+ print(f"Triage: {results.triage_level}")
21
+ for dx in results.differentials:
22
+ print(f" {dx.diagnosis}: {dx.probability:.0%}")
23
+ ```
24
+
25
+ ## Async
26
+
27
+ ```python
28
+ from nessyapi_sdk import AsyncNessyClient
29
+
30
+ async with AsyncNessyClient(api_key="nsy_live_...") as client:
31
+ session = await client.create_session("chest_pain", age=55, sex="male")
32
+ result = await client.answer(session.session_id, "q1", raw_text="pressure pain")
33
+ final = await client.finalize(session.session_id)
34
+ ```
35
+
36
+ ## Features
37
+
38
+ - Sync and async clients
39
+ - Typed response models (dataclasses)
40
+ - Automatic retry with exponential backoff (429, 5xx)
41
+ - Patient profile management
42
+ - Webhook signature verification
43
+
44
+ ## Links
45
+
46
+ - [API Reference](https://github.com/jachymvrtiskaHN/NessyAPI/blob/master/docs/api-reference.md)
47
+ - [SDK Guide](https://github.com/jachymvrtiskaHN/NessyAPI/blob/master/docs/sdk-guide.md)
@@ -0,0 +1,32 @@
1
+ """NessyAPI Python SDK."""
2
+
3
+ from nessyapi_sdk.async_client import AsyncNessyClient
4
+ from nessyapi_sdk.client import NessyAPIError, NessyClient
5
+ from nessyapi_sdk.models import (
6
+ AnswerResponse,
7
+ BalanceResponse,
8
+ Differential,
9
+ PatientProfile,
10
+ Question,
11
+ ResultsResponse,
12
+ SessionResponse,
13
+ UsageResponse,
14
+ )
15
+ from nessyapi_sdk.webhook import verify_webhook_signature
16
+
17
+ __all__ = [
18
+ "NessyClient",
19
+ "AsyncNessyClient",
20
+ "NessyAPIError",
21
+ "verify_webhook_signature",
22
+ # Response models
23
+ "SessionResponse",
24
+ "AnswerResponse",
25
+ "ResultsResponse",
26
+ "BalanceResponse",
27
+ "UsageResponse",
28
+ "PatientProfile",
29
+ "Question",
30
+ "Differential",
31
+ ]
32
+ __version__ = "0.2.0"
@@ -0,0 +1,258 @@
1
+ """
2
+ NessyAPI Python SDK — async client.
3
+
4
+ Usage:
5
+ from nessyapi_sdk import AsyncNessyClient
6
+
7
+ async with AsyncNessyClient(api_key="nsy_live_...") as client:
8
+ session = await client.create_session("headache", age=35, sex="male")
9
+ result = await client.answer(session.session_id, "q1", raw_text="3 days")
10
+ final = await client.finalize(session.session_id)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ from nessyapi_sdk.client import DEFAULT_BASE_URL, NessyAPIError, _MAX_RETRIES, _RETRYABLE_STATUSES
21
+ from nessyapi_sdk.models import (
22
+ AnswerResponse,
23
+ BalanceResponse,
24
+ PatientProfile,
25
+ ResultsResponse,
26
+ SessionResponse,
27
+ UsageResponse,
28
+ )
29
+
30
+
31
+ class AsyncNessyClient:
32
+ """Async NessyAPI client with retry and typed responses."""
33
+
34
+ def __init__(
35
+ self,
36
+ api_key: str,
37
+ base_url: str = DEFAULT_BASE_URL,
38
+ timeout: float = 30.0,
39
+ max_retries: int = _MAX_RETRIES,
40
+ ):
41
+ self._api_key = api_key
42
+ self._base_url = base_url.rstrip("/")
43
+ self._max_retries = max_retries
44
+ self._client = httpx.AsyncClient(
45
+ base_url=self._base_url,
46
+ headers={
47
+ "Authorization": f"Bearer {api_key}",
48
+ "Content-Type": "application/json",
49
+ },
50
+ timeout=timeout,
51
+ )
52
+
53
+ async def _request(self, method: str, path: str, **kwargs) -> dict[str, Any]:
54
+ last_exc: Exception | None = None
55
+ for attempt in range(self._max_retries + 1):
56
+ try:
57
+ resp = await self._client.request(method, path, **kwargs)
58
+ except httpx.TransportError as e:
59
+ last_exc = e
60
+ if attempt < self._max_retries:
61
+ await asyncio.sleep(2 ** attempt)
62
+ continue
63
+ raise
64
+
65
+ if resp.status_code < 400:
66
+ return resp.json()
67
+
68
+ if resp.status_code in _RETRYABLE_STATUSES and attempt < self._max_retries:
69
+ retry_after = resp.headers.get("Retry-After")
70
+ wait = float(retry_after) if retry_after else 2 ** attempt
71
+ await asyncio.sleep(min(wait, 60))
72
+ continue
73
+
74
+ try:
75
+ body = resp.json()
76
+ except Exception:
77
+ body = {"error": resp.text}
78
+ raise NessyAPIError(resp.status_code, body)
79
+
80
+ raise last_exc or NessyAPIError(500, {"error": "Max retries exceeded"})
81
+
82
+ # --- Session Lifecycle ---
83
+
84
+ async def create_session(
85
+ self,
86
+ chief_complaint: str,
87
+ age: int | None = None,
88
+ sex: str | None = None,
89
+ free_text: str | None = None,
90
+ ) -> SessionResponse:
91
+ """Create a new clinical session. Cost: 1 token."""
92
+ payload: dict[str, Any] = {"chief_complaint": chief_complaint}
93
+ if age is not None:
94
+ payload["age"] = age
95
+ if sex is not None:
96
+ payload["sex"] = sex
97
+ if free_text is not None:
98
+ payload["free_text"] = free_text
99
+ return SessionResponse.from_dict(await self._request("POST", "/v1/sessions", json=payload))
100
+
101
+ async def route(self, session_id: str, text: str) -> AnswerResponse:
102
+ """Route free-text to clinical branch. Cost: 5 tokens."""
103
+ data = await self._request("POST", f"/v1/sessions/{session_id}/route", json={"text": text})
104
+ return AnswerResponse.from_dict(data)
105
+
106
+ async def answer(
107
+ self,
108
+ session_id: str,
109
+ question_id: str,
110
+ *,
111
+ raw_text: str | None = None,
112
+ extracted_fields: dict[str, Any] | None = None,
113
+ extracted_flags: list[str] | None = None,
114
+ skip: bool = False,
115
+ ) -> AnswerResponse:
116
+ """Submit a patient answer. Cost: 8 tokens (0 if skip)."""
117
+ payload: dict[str, Any] = {"question_id": question_id}
118
+ if raw_text is not None:
119
+ payload["raw_text"] = raw_text
120
+ if extracted_fields:
121
+ payload["extracted_fields"] = extracted_fields
122
+ if extracted_flags:
123
+ payload["extracted_flags"] = extracted_flags
124
+ if skip:
125
+ payload["skip"] = True
126
+ return AnswerResponse.from_dict(
127
+ await self._request("POST", f"/v1/sessions/{session_id}/answer", json=payload)
128
+ )
129
+
130
+ async def get_results(self, session_id: str) -> ResultsResponse:
131
+ """Get current differentials and triage. FREE."""
132
+ return ResultsResponse.from_dict(
133
+ await self._request("GET", f"/v1/sessions/{session_id}/results")
134
+ )
135
+
136
+ async def get_state(self, session_id: str) -> dict[str, Any]:
137
+ """Get session state. FREE."""
138
+ return await self._request("GET", f"/v1/sessions/{session_id}/state")
139
+
140
+ async def finalize(self, session_id: str) -> ResultsResponse:
141
+ """Finalize session and get full results. FREE."""
142
+ return ResultsResponse.from_dict(
143
+ await self._request("POST", f"/v1/sessions/{session_id}/finalize")
144
+ )
145
+
146
+ # --- Patient Profiles ---
147
+
148
+ async def get_patient(self, patient_id: str) -> PatientProfile:
149
+ """Get patient profile."""
150
+ return PatientProfile.from_dict(
151
+ await self._request("GET", f"/v1/patients/{patient_id}/profile")
152
+ )
153
+
154
+ async def update_patient(
155
+ self,
156
+ patient_id: str,
157
+ *,
158
+ chronic_conditions: list[str] | None = None,
159
+ current_medications: list[str] | None = None,
160
+ allergies: list[str] | None = None,
161
+ risk_factors: list[str] | None = None,
162
+ family_history: list[str] | None = None,
163
+ smoking_status: str | None = None,
164
+ alcohol_status: str | None = None,
165
+ ) -> PatientProfile:
166
+ """Create or update patient profile (merge-append for arrays)."""
167
+ payload: dict[str, Any] = {}
168
+ if chronic_conditions is not None:
169
+ payload["chronic_conditions"] = chronic_conditions
170
+ if current_medications is not None:
171
+ payload["current_medications"] = current_medications
172
+ if allergies is not None:
173
+ payload["allergies"] = allergies
174
+ if risk_factors is not None:
175
+ payload["risk_factors"] = risk_factors
176
+ if family_history is not None:
177
+ payload["family_history"] = family_history
178
+ if smoking_status is not None:
179
+ payload["smoking_status"] = smoking_status
180
+ if alcohol_status is not None:
181
+ payload["alcohol_status"] = alcohol_status
182
+ return PatientProfile.from_dict(
183
+ await self._request("PUT", f"/v1/patients/{patient_id}/profile", json=payload)
184
+ )
185
+
186
+ async def list_patient_sessions(self, patient_id: str) -> list[dict[str, Any]]:
187
+ """List sessions for a patient."""
188
+ data = await self._request("GET", f"/v1/patients/{patient_id}/sessions")
189
+ return data.get("sessions", data) if isinstance(data, dict) else data
190
+
191
+ async def delete_patient(self, patient_id: str) -> dict[str, Any]:
192
+ """Delete patient and all associated data."""
193
+ return await self._request("DELETE", f"/v1/patients/{patient_id}")
194
+
195
+ # --- Admin ---
196
+
197
+ async def get_balance(self) -> BalanceResponse:
198
+ """Get current token balance."""
199
+ return BalanceResponse.from_dict(await self._request("GET", "/v1/admin/balance"))
200
+
201
+ async def get_usage(self, days: int = 30) -> UsageResponse:
202
+ """Get token usage for last N days."""
203
+ return UsageResponse.from_dict(await self._request("GET", f"/v1/admin/usage?days={days}"))
204
+
205
+ async def list_keys(self) -> list[dict[str, Any]]:
206
+ """List all API keys."""
207
+ return await self._request("GET", "/v1/admin/keys")
208
+
209
+ async def create_key(self, name: str) -> dict[str, Any]:
210
+ """Create a new API key."""
211
+ return await self._request("POST", "/v1/admin/keys", json={"name": name})
212
+
213
+ async def revoke_key(self, key_id: str) -> dict[str, Any]:
214
+ """Revoke an API key."""
215
+ return await self._request("DELETE", f"/v1/admin/keys/{key_id}")
216
+
217
+ # --- Convenience ---
218
+
219
+ async def run_assessment(
220
+ self,
221
+ chief_complaint: str,
222
+ age: int | None = None,
223
+ sex: str | None = None,
224
+ answers: dict[str, str] | None = None,
225
+ max_questions: int = 15,
226
+ ) -> ResultsResponse:
227
+ """
228
+ Run a full assessment end-to-end.
229
+ If answers is None, auto-skips all questions.
230
+ Cost: 1 + N*8 tokens (N = answered questions).
231
+ """
232
+ session = await self.create_session(chief_complaint=chief_complaint, age=age, sex=sex)
233
+ session_id = session.session_id
234
+ q = session.current_question
235
+ asked = 0
236
+
237
+ while q and asked < max_questions:
238
+ qid = q.question_id
239
+ if answers and qid in answers:
240
+ result = await self.answer(session_id, qid, raw_text=answers[qid])
241
+ else:
242
+ result = await self.answer(session_id, qid, skip=True)
243
+ asked += 1
244
+ if result.is_complete:
245
+ break
246
+ q = result.current_question
247
+
248
+ return await self.finalize(session_id)
249
+
250
+ async def close(self):
251
+ """Close the HTTP connection."""
252
+ await self._client.aclose()
253
+
254
+ async def __aenter__(self):
255
+ return self
256
+
257
+ async def __aexit__(self, *args):
258
+ await self.close()
@@ -0,0 +1,269 @@
1
+ """
2
+ NessyAPI Python SDK — synchronous client.
3
+
4
+ Usage:
5
+ from nessyapi_sdk import NessyClient
6
+
7
+ with NessyClient(api_key="nsy_live_...") as client:
8
+ session = client.create_session("headache", age=35, sex="male")
9
+ result = client.answer(session.session_id, "q1", raw_text="3 days")
10
+ final = client.finalize(session.session_id)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ from nessyapi_sdk.models import (
21
+ AnswerResponse,
22
+ BalanceResponse,
23
+ PatientProfile,
24
+ ResultsResponse,
25
+ SessionResponse,
26
+ UsageResponse,
27
+ )
28
+
29
+ DEFAULT_BASE_URL = "https://nessyapi.bravemeadow-4ea62cad.northeurope.azurecontainerapps.io"
30
+ _RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
31
+ _MAX_RETRIES = 3
32
+
33
+
34
+ class NessyAPIError(Exception):
35
+ """Raised on HTTP 4xx/5xx responses from NessyAPI."""
36
+
37
+ def __init__(self, status: int, body: dict[str, Any]):
38
+ self.status = status
39
+ self.body = body
40
+ detail = body.get("detail", body.get("error", f"HTTP {status}"))
41
+ super().__init__(f"NessyAPI error {status}: {detail}")
42
+
43
+
44
+ class NessyClient:
45
+ """Synchronous NessyAPI client with retry and typed responses."""
46
+
47
+ def __init__(
48
+ self,
49
+ api_key: str,
50
+ base_url: str = DEFAULT_BASE_URL,
51
+ timeout: float = 30.0,
52
+ max_retries: int = _MAX_RETRIES,
53
+ ):
54
+ self._api_key = api_key
55
+ self._base_url = base_url.rstrip("/")
56
+ self._max_retries = max_retries
57
+ self._client = httpx.Client(
58
+ base_url=self._base_url,
59
+ headers={
60
+ "Authorization": f"Bearer {api_key}",
61
+ "Content-Type": "application/json",
62
+ },
63
+ timeout=timeout,
64
+ )
65
+
66
+ def _request(self, method: str, path: str, **kwargs) -> dict[str, Any]:
67
+ last_exc: Exception | None = None
68
+ for attempt in range(self._max_retries + 1):
69
+ try:
70
+ resp = self._client.request(method, path, **kwargs)
71
+ except httpx.TransportError as e:
72
+ last_exc = e
73
+ if attempt < self._max_retries:
74
+ time.sleep(2 ** attempt)
75
+ continue
76
+ raise
77
+
78
+ if resp.status_code < 400:
79
+ return resp.json()
80
+
81
+ if resp.status_code in _RETRYABLE_STATUSES and attempt < self._max_retries:
82
+ retry_after = resp.headers.get("Retry-After")
83
+ wait = float(retry_after) if retry_after else 2 ** attempt
84
+ time.sleep(min(wait, 60))
85
+ continue
86
+
87
+ try:
88
+ body = resp.json()
89
+ except Exception:
90
+ body = {"error": resp.text}
91
+ raise NessyAPIError(resp.status_code, body)
92
+
93
+ raise last_exc or NessyAPIError(500, {"error": "Max retries exceeded"})
94
+
95
+ # --- Session Lifecycle ---
96
+
97
+ def create_session(
98
+ self,
99
+ chief_complaint: str,
100
+ age: int | None = None,
101
+ sex: str | None = None,
102
+ free_text: str | None = None,
103
+ ) -> SessionResponse:
104
+ """Create a new clinical session. Cost: 1 token."""
105
+ payload: dict[str, Any] = {"chief_complaint": chief_complaint}
106
+ if age is not None:
107
+ payload["age"] = age
108
+ if sex is not None:
109
+ payload["sex"] = sex
110
+ if free_text is not None:
111
+ payload["free_text"] = free_text
112
+ return SessionResponse.from_dict(self._request("POST", "/v1/sessions", json=payload))
113
+
114
+ def route(self, session_id: str, text: str) -> AnswerResponse:
115
+ """Route free-text to clinical branch. Cost: 5 tokens."""
116
+ data = self._request("POST", f"/v1/sessions/{session_id}/route", json={"text": text})
117
+ return AnswerResponse.from_dict(data)
118
+
119
+ def answer(
120
+ self,
121
+ session_id: str,
122
+ question_id: str,
123
+ *,
124
+ raw_text: str | None = None,
125
+ extracted_fields: dict[str, Any] | None = None,
126
+ extracted_flags: list[str] | None = None,
127
+ skip: bool = False,
128
+ ) -> AnswerResponse:
129
+ """Submit a patient answer. Cost: 8 tokens (0 if skip)."""
130
+ payload: dict[str, Any] = {"question_id": question_id}
131
+ if raw_text is not None:
132
+ payload["raw_text"] = raw_text
133
+ if extracted_fields:
134
+ payload["extracted_fields"] = extracted_fields
135
+ if extracted_flags:
136
+ payload["extracted_flags"] = extracted_flags
137
+ if skip:
138
+ payload["skip"] = True
139
+ return AnswerResponse.from_dict(
140
+ self._request("POST", f"/v1/sessions/{session_id}/answer", json=payload)
141
+ )
142
+
143
+ def get_results(self, session_id: str) -> ResultsResponse:
144
+ """Get current differentials and triage. FREE."""
145
+ return ResultsResponse.from_dict(self._request("GET", f"/v1/sessions/{session_id}/results"))
146
+
147
+ def get_state(self, session_id: str) -> dict[str, Any]:
148
+ """Get session state. FREE."""
149
+ return self._request("GET", f"/v1/sessions/{session_id}/state")
150
+
151
+ def finalize(self, session_id: str) -> ResultsResponse:
152
+ """Finalize session and get full results. FREE."""
153
+ return ResultsResponse.from_dict(
154
+ self._request("POST", f"/v1/sessions/{session_id}/finalize")
155
+ )
156
+
157
+ # --- Patient Profiles ---
158
+
159
+ def get_patient(self, patient_id: str) -> PatientProfile:
160
+ """Get patient profile."""
161
+ return PatientProfile.from_dict(
162
+ self._request("GET", f"/v1/patients/{patient_id}/profile")
163
+ )
164
+
165
+ def update_patient(
166
+ self,
167
+ patient_id: str,
168
+ *,
169
+ chronic_conditions: list[str] | None = None,
170
+ current_medications: list[str] | None = None,
171
+ allergies: list[str] | None = None,
172
+ risk_factors: list[str] | None = None,
173
+ family_history: list[str] | None = None,
174
+ smoking_status: str | None = None,
175
+ alcohol_status: str | None = None,
176
+ ) -> PatientProfile:
177
+ """Create or update patient profile (merge-append for arrays)."""
178
+ payload: dict[str, Any] = {}
179
+ if chronic_conditions is not None:
180
+ payload["chronic_conditions"] = chronic_conditions
181
+ if current_medications is not None:
182
+ payload["current_medications"] = current_medications
183
+ if allergies is not None:
184
+ payload["allergies"] = allergies
185
+ if risk_factors is not None:
186
+ payload["risk_factors"] = risk_factors
187
+ if family_history is not None:
188
+ payload["family_history"] = family_history
189
+ if smoking_status is not None:
190
+ payload["smoking_status"] = smoking_status
191
+ if alcohol_status is not None:
192
+ payload["alcohol_status"] = alcohol_status
193
+ return PatientProfile.from_dict(
194
+ self._request("PUT", f"/v1/patients/{patient_id}/profile", json=payload)
195
+ )
196
+
197
+ def list_patient_sessions(self, patient_id: str) -> list[dict[str, Any]]:
198
+ """List sessions for a patient."""
199
+ data = self._request("GET", f"/v1/patients/{patient_id}/sessions")
200
+ return data.get("sessions", data) if isinstance(data, dict) else data
201
+
202
+ def delete_patient(self, patient_id: str) -> dict[str, Any]:
203
+ """Delete patient and all associated data."""
204
+ return self._request("DELETE", f"/v1/patients/{patient_id}")
205
+
206
+ # --- Admin ---
207
+
208
+ def get_balance(self) -> BalanceResponse:
209
+ """Get current token balance."""
210
+ return BalanceResponse.from_dict(self._request("GET", "/v1/admin/balance"))
211
+
212
+ def get_usage(self, days: int = 30) -> UsageResponse:
213
+ """Get token usage for last N days."""
214
+ return UsageResponse.from_dict(self._request("GET", f"/v1/admin/usage?days={days}"))
215
+
216
+ def list_keys(self) -> list[dict[str, Any]]:
217
+ """List all API keys."""
218
+ return self._request("GET", "/v1/admin/keys")
219
+
220
+ def create_key(self, name: str) -> dict[str, Any]:
221
+ """Create a new API key."""
222
+ return self._request("POST", "/v1/admin/keys", json={"name": name})
223
+
224
+ def revoke_key(self, key_id: str) -> dict[str, Any]:
225
+ """Revoke an API key."""
226
+ return self._request("DELETE", f"/v1/admin/keys/{key_id}")
227
+
228
+ # --- Convenience ---
229
+
230
+ def run_assessment(
231
+ self,
232
+ chief_complaint: str,
233
+ age: int | None = None,
234
+ sex: str | None = None,
235
+ answers: dict[str, str] | None = None,
236
+ max_questions: int = 15,
237
+ ) -> ResultsResponse:
238
+ """
239
+ Run a full assessment end-to-end.
240
+ If answers is None, auto-skips all questions.
241
+ Cost: 1 + N*8 tokens (N = answered questions).
242
+ """
243
+ session = self.create_session(chief_complaint=chief_complaint, age=age, sex=sex)
244
+ session_id = session.session_id
245
+ q = session.current_question
246
+ asked = 0
247
+
248
+ while q and asked < max_questions:
249
+ qid = q.question_id
250
+ if answers and qid in answers:
251
+ result = self.answer(session_id, qid, raw_text=answers[qid])
252
+ else:
253
+ result = self.answer(session_id, qid, skip=True)
254
+ asked += 1
255
+ if result.is_complete:
256
+ break
257
+ q = result.current_question
258
+
259
+ return self.finalize(session_id)
260
+
261
+ def close(self):
262
+ """Close the HTTP connection."""
263
+ self._client.close()
264
+
265
+ def __enter__(self):
266
+ return self
267
+
268
+ def __exit__(self, *args):
269
+ self.close()
@@ -0,0 +1,163 @@
1
+ """Typed response models for NessyAPI SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class Question:
11
+ question_id: str
12
+ text: str
13
+ answer_type: str = ""
14
+ options: list[str] = field(default_factory=list)
15
+ branch: str = ""
16
+
17
+ @classmethod
18
+ def from_dict(cls, d: dict[str, Any] | None) -> Question | None:
19
+ if not d:
20
+ return None
21
+ return cls(
22
+ question_id=d.get("question_id", ""),
23
+ text=d.get("text", ""),
24
+ answer_type=d.get("answer_type", ""),
25
+ options=d.get("options", []),
26
+ branch=d.get("branch", ""),
27
+ )
28
+
29
+
30
+ @dataclass
31
+ class Differential:
32
+ diagnosis: str
33
+ probability: float
34
+ icd10: str = ""
35
+
36
+ @classmethod
37
+ def from_dict(cls, d: dict[str, Any]) -> Differential:
38
+ return cls(
39
+ diagnosis=d.get("diagnosis", ""),
40
+ probability=d.get("probability", 0.0),
41
+ icd10=d.get("icd10", ""),
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class SessionResponse:
47
+ session_id: str
48
+ status: str
49
+ current_question: Question | None = None
50
+ raw: dict[str, Any] = field(default_factory=dict)
51
+
52
+ @classmethod
53
+ def from_dict(cls, d: dict[str, Any]) -> SessionResponse:
54
+ return cls(
55
+ session_id=d.get("session_id", ""),
56
+ status=d.get("status", ""),
57
+ current_question=Question.from_dict(d.get("current_question")),
58
+ raw=d,
59
+ )
60
+
61
+
62
+ @dataclass
63
+ class AnswerResponse:
64
+ session_id: str
65
+ is_complete: bool
66
+ current_question: Question | None = None
67
+ questions_asked: int = 0
68
+ raw: dict[str, Any] = field(default_factory=dict)
69
+
70
+ @classmethod
71
+ def from_dict(cls, d: dict[str, Any]) -> AnswerResponse:
72
+ return cls(
73
+ session_id=d.get("session_id", ""),
74
+ is_complete=d.get("is_complete", False),
75
+ current_question=Question.from_dict(d.get("current_question")),
76
+ questions_asked=d.get("questions_asked", 0),
77
+ raw=d,
78
+ )
79
+
80
+
81
+ @dataclass
82
+ class ResultsResponse:
83
+ session_id: str
84
+ triage_level: str
85
+ differentials: list[Differential]
86
+ red_flags: list[str]
87
+ recommendations: list[str] = field(default_factory=list)
88
+ raw: dict[str, Any] = field(default_factory=dict)
89
+
90
+ @classmethod
91
+ def from_dict(cls, d: dict[str, Any]) -> ResultsResponse:
92
+ return cls(
93
+ session_id=d.get("session_id", ""),
94
+ triage_level=d.get("triage_level", d.get("triage", {}).get("level", "")),
95
+ differentials=[
96
+ Differential.from_dict(dx)
97
+ for dx in d.get("differentials", [])
98
+ ],
99
+ red_flags=d.get("red_flags", []),
100
+ recommendations=d.get("recommendations", []),
101
+ raw=d,
102
+ )
103
+
104
+
105
+ @dataclass
106
+ class BalanceResponse:
107
+ balance: int
108
+ lifetime_used: int
109
+ tier: str
110
+ raw: dict[str, Any] = field(default_factory=dict)
111
+
112
+ @classmethod
113
+ def from_dict(cls, d: dict[str, Any]) -> BalanceResponse:
114
+ return cls(
115
+ balance=d.get("balance", 0),
116
+ lifetime_used=d.get("lifetime_used", 0),
117
+ tier=d.get("tier", ""),
118
+ raw=d,
119
+ )
120
+
121
+
122
+ @dataclass
123
+ class UsageResponse:
124
+ total_tokens: int
125
+ days: int
126
+ daily: list[dict[str, Any]] = field(default_factory=list)
127
+ raw: dict[str, Any] = field(default_factory=dict)
128
+
129
+ @classmethod
130
+ def from_dict(cls, d: dict[str, Any]) -> UsageResponse:
131
+ return cls(
132
+ total_tokens=d.get("total_tokens", 0),
133
+ days=d.get("days", 30),
134
+ daily=d.get("daily", []),
135
+ raw=d,
136
+ )
137
+
138
+
139
+ @dataclass
140
+ class PatientProfile:
141
+ partner_patient_id: str
142
+ chronic_conditions: list[str] = field(default_factory=list)
143
+ current_medications: list[str] = field(default_factory=list)
144
+ allergies: list[str] = field(default_factory=list)
145
+ risk_factors: list[str] = field(default_factory=list)
146
+ family_history: list[str] = field(default_factory=list)
147
+ smoking_status: str = ""
148
+ alcohol_status: str = ""
149
+ raw: dict[str, Any] = field(default_factory=dict)
150
+
151
+ @classmethod
152
+ def from_dict(cls, d: dict[str, Any]) -> PatientProfile:
153
+ return cls(
154
+ partner_patient_id=d.get("partner_patient_id", ""),
155
+ chronic_conditions=d.get("chronic_conditions", []),
156
+ current_medications=d.get("current_medications", []),
157
+ allergies=d.get("allergies", []),
158
+ risk_factors=d.get("risk_factors", []),
159
+ family_history=d.get("family_history", []),
160
+ smoking_status=d.get("smoking_status", ""),
161
+ alcohol_status=d.get("alcohol_status", ""),
162
+ raw=d,
163
+ )
@@ -0,0 +1,55 @@
1
+ """
2
+ Webhook signature verification helper.
3
+
4
+ Usage:
5
+ from nessyapi_sdk import verify_webhook_signature
6
+
7
+ # In your webhook handler:
8
+ is_valid = verify_webhook_signature(
9
+ payload=request.body,
10
+ secret="your-signing-secret",
11
+ signature=request.headers["X-NessyAPI-Signature-256"],
12
+ )
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import hmac
19
+
20
+
21
+ def verify_webhook_signature(
22
+ payload: bytes,
23
+ secret: str,
24
+ signature: str,
25
+ timestamp: str | None = None,
26
+ ) -> bool:
27
+ """
28
+ Verify a NessyAPI webhook signature.
29
+
30
+ Args:
31
+ payload: Raw request body bytes
32
+ secret: Your webhook signing secret
33
+ signature: Value of X-NessyAPI-Signature-256 header
34
+ timestamp: Value of X-NessyAPI-Timestamp header (required for
35
+ replay-safe verification — the server includes the
36
+ timestamp in the signed content)
37
+
38
+ Returns:
39
+ True if signature is valid
40
+ """
41
+ if not signature.startswith("sha256="):
42
+ return False
43
+
44
+ # The server signs: payload + "." + timestamp (see signer.py)
45
+ data = payload
46
+ if timestamp:
47
+ data = payload + b"." + timestamp.encode("utf-8")
48
+
49
+ expected = "sha256=" + hmac.new(
50
+ secret.encode("utf-8"),
51
+ data,
52
+ hashlib.sha256,
53
+ ).hexdigest()
54
+
55
+ return hmac.compare_digest(expected, signature)
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: nessyapi-sdk
3
+ Version: 0.2.0
4
+ Summary: Python SDK for NessyAPI — Clinical Decision Support API
5
+ Author-email: "HealthyNess.cz" <dev@healthyness.cz>
6
+ License: MIT
7
+ Project-URL: Homepage, https://healthyness.cz
8
+ Project-URL: Documentation, https://github.com/jachymvrtiskaHN/NessyAPI/tree/master/sdk
9
+ Project-URL: Repository, https://github.com/jachymvrtiskaHN/NessyAPI
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Healthcare Industry
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: httpx>=0.27
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
27
+
28
+ # NessyAPI Python SDK
29
+
30
+ Python SDK for [NessyAPI](https://healthyness.cz) — Clinical Decision Support API.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install nessyapi-sdk
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ```python
41
+ from nessyapi_sdk import NessyClient
42
+
43
+ with NessyClient(api_key="nsy_live_...") as client:
44
+ # Run a full assessment
45
+ results = client.run_assessment("headache", age=35, sex="male")
46
+
47
+ print(f"Triage: {results.triage_level}")
48
+ for dx in results.differentials:
49
+ print(f" {dx.diagnosis}: {dx.probability:.0%}")
50
+ ```
51
+
52
+ ## Async
53
+
54
+ ```python
55
+ from nessyapi_sdk import AsyncNessyClient
56
+
57
+ async with AsyncNessyClient(api_key="nsy_live_...") as client:
58
+ session = await client.create_session("chest_pain", age=55, sex="male")
59
+ result = await client.answer(session.session_id, "q1", raw_text="pressure pain")
60
+ final = await client.finalize(session.session_id)
61
+ ```
62
+
63
+ ## Features
64
+
65
+ - Sync and async clients
66
+ - Typed response models (dataclasses)
67
+ - Automatic retry with exponential backoff (429, 5xx)
68
+ - Patient profile management
69
+ - Webhook signature verification
70
+
71
+ ## Links
72
+
73
+ - [API Reference](https://github.com/jachymvrtiskaHN/NessyAPI/blob/master/docs/api-reference.md)
74
+ - [SDK Guide](https://github.com/jachymvrtiskaHN/NessyAPI/blob/master/docs/sdk-guide.md)
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ nessyapi_sdk/__init__.py
4
+ nessyapi_sdk/async_client.py
5
+ nessyapi_sdk/client.py
6
+ nessyapi_sdk/models.py
7
+ nessyapi_sdk/webhook.py
8
+ nessyapi_sdk.egg-info/PKG-INFO
9
+ nessyapi_sdk.egg-info/SOURCES.txt
10
+ nessyapi_sdk.egg-info/dependency_links.txt
11
+ nessyapi_sdk.egg-info/requires.txt
12
+ nessyapi_sdk.egg-info/top_level.txt
13
+ tests/test_live.py
@@ -0,0 +1,5 @@
1
+ httpx>=0.27
2
+
3
+ [dev]
4
+ pytest>=7.0
5
+ pytest-asyncio>=0.21
@@ -0,0 +1 @@
1
+ nessyapi_sdk
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "nessyapi-sdk"
3
+ version = "0.2.0"
4
+ description = "Python SDK for NessyAPI — Clinical Decision Support API"
5
+ readme = "README.md"
6
+ license = {text = "MIT"}
7
+ requires-python = ">=3.10"
8
+ authors = [
9
+ {name = "HealthyNess.cz", email = "dev@healthyness.cz"},
10
+ ]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Intended Audience :: Healthcare Industry",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = [
25
+ "httpx>=0.27",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=7.0", "pytest-asyncio>=0.21"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://healthyness.cz"
33
+ Documentation = "https://github.com/jachymvrtiskaHN/NessyAPI/tree/master/sdk"
34
+ Repository = "https://github.com/jachymvrtiskaHN/NessyAPI"
35
+
36
+ [build-system]
37
+ requires = ["setuptools>=68.0"]
38
+ build-backend = "setuptools.build_meta"
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["."]
42
+ include = ["nessyapi_sdk*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,168 @@
1
+ """
2
+ SDK live API tests.
3
+
4
+ Run with:
5
+ NESSYAPI_URL=https://nessyapi.xxx.northeurope.azurecontainerapps.io \
6
+ NESSYAPI_KEY=nsy_live_xxx \
7
+ pytest sdk/tests/test_live.py -v
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import hmac
14
+ import os
15
+ import sys
16
+
17
+ import pytest
18
+
19
+ # Add SDK to path
20
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
21
+
22
+ from nessyapi_sdk import NessyClient, verify_webhook_signature
23
+
24
+ API_URL = os.environ.get("NESSYAPI_URL", "")
25
+ API_KEY = os.environ.get("NESSYAPI_KEY", "")
26
+
27
+ pytestmark = pytest.mark.skipif(
28
+ not API_URL or not API_KEY,
29
+ reason="NESSYAPI_URL and NESSYAPI_KEY required",
30
+ )
31
+
32
+
33
+ @pytest.fixture
34
+ def client():
35
+ c = NessyClient(api_key=API_KEY, base_url=API_URL, timeout=30)
36
+ yield c
37
+ c.close()
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Health
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def test_health():
46
+ """Health endpoint is public and returns 200."""
47
+ import httpx
48
+
49
+ r = httpx.get(f"{API_URL}/health", timeout=10)
50
+ assert r.status_code == 200
51
+ data = r.json()
52
+ assert data.get("engine_ready") is True
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Balance & Admin
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ def test_balance(client):
61
+ b = client.get_balance()
62
+ assert b["balance"] >= 0
63
+ assert "tier" in b
64
+
65
+
66
+ def test_list_keys(client):
67
+ keys = client.list_keys()
68
+ assert isinstance(keys, list)
69
+ assert len(keys) > 0
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Session Lifecycle
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ def test_create_session(client):
78
+ s = client.create_session(chief_complaint="headache", age=30, sex="female")
79
+ assert "session_id" in s
80
+ assert s["current_question"] is not None
81
+
82
+
83
+ def test_full_session_lifecycle(client):
84
+ """Create -> answer -> results (FREE) -> finalize."""
85
+ s = client.create_session(chief_complaint="chest_pain", age=55, sex="male")
86
+ sid = s["session_id"]
87
+ qid = s["current_question"]["question_id"]
88
+
89
+ # Answer one question
90
+ a = client.answer(sid, qid, extracted_fields={"quality": "crushing"})
91
+ assert a["questions_asked"] >= 1
92
+
93
+ # Results (FREE — 0 tokens)
94
+ r = client.get_results(sid)
95
+ assert len(r["differentials"]) > 0
96
+
97
+ # Finalize
98
+ f = client.finalize(sid)
99
+ assert f["status"] == "finalized"
100
+
101
+
102
+ def test_session_state(client):
103
+ """get_state returns active session info."""
104
+ s = client.create_session(chief_complaint="headache", age=25, sex="male")
105
+ sid = s["session_id"]
106
+
107
+ state = client.get_state(sid)
108
+ assert state["status"] == "active"
109
+ assert state["session_id"] == sid
110
+
111
+
112
+ def test_answer_with_raw_text(client):
113
+ """Answer using raw_text instead of extracted_fields."""
114
+ s = client.create_session(chief_complaint="headache", age=40, sex="female")
115
+ sid = s["session_id"]
116
+ qid = s["current_question"]["question_id"]
117
+
118
+ a = client.answer(sid, qid, raw_text="It started two days ago, throbbing pain")
119
+ assert a["questions_asked"] >= 1
120
+
121
+
122
+ def test_answer_skip(client):
123
+ """Skipping a question advances the session."""
124
+ s = client.create_session(chief_complaint="headache", age=35, sex="male")
125
+ sid = s["session_id"]
126
+ qid = s["current_question"]["question_id"]
127
+
128
+ a = client.answer(sid, qid, skip=True)
129
+ assert a["questions_asked"] >= 1
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Convenience method
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ def test_run_assessment(client):
138
+ """Test the convenience run_assessment method (auto-skip all questions)."""
139
+ result = client.run_assessment(chief_complaint="headache", age=40, sex="male")
140
+ assert result["status"] == "finalized"
141
+ assert result.get("primary_diagnosis") is not None
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Webhook Signature (offline — no API call needed)
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ def test_webhook_signature_verification():
150
+ """Test the SDK's webhook signature verification helper."""
151
+ payload = b'{"event": "session.finalized", "session_id": "abc123"}'
152
+ secret = "test-secret-123"
153
+
154
+ sig = "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
155
+
156
+ assert verify_webhook_signature(payload, secret, sig) is True
157
+ assert verify_webhook_signature(payload, "wrong-secret", sig) is False
158
+ assert verify_webhook_signature(b"tampered", secret, sig) is False
159
+
160
+
161
+ def test_webhook_signature_rejects_invalid_format():
162
+ """Signatures without sha256= prefix are rejected."""
163
+ payload = b'{"event": "test"}'
164
+ secret = "s"
165
+ raw_hash = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
166
+
167
+ assert verify_webhook_signature(payload, secret, raw_hash) is False
168
+ assert verify_webhook_signature(payload, secret, f"md5={raw_hash}") is False