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.
- nessyapi_sdk-0.2.0/PKG-INFO +74 -0
- nessyapi_sdk-0.2.0/README.md +47 -0
- nessyapi_sdk-0.2.0/nessyapi_sdk/__init__.py +32 -0
- nessyapi_sdk-0.2.0/nessyapi_sdk/async_client.py +258 -0
- nessyapi_sdk-0.2.0/nessyapi_sdk/client.py +269 -0
- nessyapi_sdk-0.2.0/nessyapi_sdk/models.py +163 -0
- nessyapi_sdk-0.2.0/nessyapi_sdk/webhook.py +55 -0
- nessyapi_sdk-0.2.0/nessyapi_sdk.egg-info/PKG-INFO +74 -0
- nessyapi_sdk-0.2.0/nessyapi_sdk.egg-info/SOURCES.txt +13 -0
- nessyapi_sdk-0.2.0/nessyapi_sdk.egg-info/dependency_links.txt +1 -0
- nessyapi_sdk-0.2.0/nessyapi_sdk.egg-info/requires.txt +5 -0
- nessyapi_sdk-0.2.0/nessyapi_sdk.egg-info/top_level.txt +1 -0
- nessyapi_sdk-0.2.0/pyproject.toml +42 -0
- nessyapi_sdk-0.2.0/setup.cfg +4 -0
- nessyapi_sdk-0.2.0/tests/test_live.py +168 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|