hunter-seeker 2.0.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.
- hunter_seeker-2.0.0/PKG-INFO +47 -0
- hunter_seeker-2.0.0/README.md +29 -0
- hunter_seeker-2.0.0/hunter_seeker/__init__.py +16 -0
- hunter_seeker-2.0.0/hunter_seeker/cli.py +91 -0
- hunter_seeker-2.0.0/hunter_seeker/client.py +169 -0
- hunter_seeker-2.0.0/hunter_seeker/crewai.py +33 -0
- hunter_seeker-2.0.0/hunter_seeker/langchain.py +61 -0
- hunter_seeker-2.0.0/hunter_seeker.egg-info/PKG-INFO +47 -0
- hunter_seeker-2.0.0/hunter_seeker.egg-info/SOURCES.txt +14 -0
- hunter_seeker-2.0.0/hunter_seeker.egg-info/dependency_links.txt +1 -0
- hunter_seeker-2.0.0/hunter_seeker.egg-info/entry_points.txt +2 -0
- hunter_seeker-2.0.0/hunter_seeker.egg-info/requires.txt +8 -0
- hunter_seeker-2.0.0/hunter_seeker.egg-info/top_level.txt +1 -0
- hunter_seeker-2.0.0/pyproject.toml +21 -0
- hunter_seeker-2.0.0/setup.cfg +4 -0
- hunter_seeker-2.0.0/tests/test_client.py +41 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hunter-seeker
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Python client and framework tools for the Hunter-Seeker Verdict layer: deterministic, signed, refusable decisions for agents.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/dmilstein-match/hunter-seeker-sdk
|
|
7
|
+
Project-URL: Source, https://github.com/dmilstein-match/hunter-seeker-sdk
|
|
8
|
+
Project-URL: Documentation, https://hunter-seeker.net/docs
|
|
9
|
+
Project-URL: Changelog, https://github.com/dmilstein-match/hunter-seeker-sdk/blob/main/CHANGELOG.md
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: hs-verify>=0.1
|
|
13
|
+
Provides-Extra: langchain
|
|
14
|
+
Requires-Dist: langchain-core>=0.3; extra == "langchain"
|
|
15
|
+
Provides-Extra: crewai
|
|
16
|
+
Requires-Dist: crewai>=0.100; extra == "crewai"
|
|
17
|
+
Requires-Dist: crewai-tools>=0.40; extra == "crewai"
|
|
18
|
+
|
|
19
|
+
# hunter-seeker
|
|
20
|
+
|
|
21
|
+
Python client, `hs` CLI and framework adapters for the **Hunter-Seeker Verdict layer** —
|
|
22
|
+
deterministic, signed, refusable decisions for AI agents.
|
|
23
|
+
|
|
24
|
+
> Agents may generate copy. They may not invent the score.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install hunter-seeker
|
|
28
|
+
export HS_API_KEY=hsk_test_...
|
|
29
|
+
hs sample # ranks a sample dataset and verifies the Verdict
|
|
30
|
+
```
|
|
31
|
+
```python
|
|
32
|
+
from hunter_seeker import Client
|
|
33
|
+
hs = Client()
|
|
34
|
+
out = hs.score_entity(model_ref=ref, row=row, subject_kind="org")
|
|
35
|
+
out["entity"]["band"] # act | escalate | refuse
|
|
36
|
+
hs.verify(out["verdict"], out["signature"]) # valid | invalid_signature | expired | unknown_key
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Extras: `hunter-seeker[langchain]`, `hunter-seeker[crewai]`.
|
|
40
|
+
|
|
41
|
+
The client refuses to call an unsigned Verdict valid — a missing signature reports
|
|
42
|
+
`invalid_signature`, because production engines will not serve one.
|
|
43
|
+
|
|
44
|
+
**Pre-release:** the hosted service is not serving yet. Full docs, the OpenAPI contract, the
|
|
45
|
+
n8n node and Agent Skills: https://github.com/dmilstein-match/hunter-seeker-sdk
|
|
46
|
+
|
|
47
|
+
Apache-2.0.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# hunter-seeker
|
|
2
|
+
|
|
3
|
+
Python client, `hs` CLI and framework adapters for the **Hunter-Seeker Verdict layer** —
|
|
4
|
+
deterministic, signed, refusable decisions for AI agents.
|
|
5
|
+
|
|
6
|
+
> Agents may generate copy. They may not invent the score.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install hunter-seeker
|
|
10
|
+
export HS_API_KEY=hsk_test_...
|
|
11
|
+
hs sample # ranks a sample dataset and verifies the Verdict
|
|
12
|
+
```
|
|
13
|
+
```python
|
|
14
|
+
from hunter_seeker import Client
|
|
15
|
+
hs = Client()
|
|
16
|
+
out = hs.score_entity(model_ref=ref, row=row, subject_kind="org")
|
|
17
|
+
out["entity"]["band"] # act | escalate | refuse
|
|
18
|
+
hs.verify(out["verdict"], out["signature"]) # valid | invalid_signature | expired | unknown_key
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Extras: `hunter-seeker[langchain]`, `hunter-seeker[crewai]`.
|
|
22
|
+
|
|
23
|
+
The client refuses to call an unsigned Verdict valid — a missing signature reports
|
|
24
|
+
`invalid_signature`, because production engines will not serve one.
|
|
25
|
+
|
|
26
|
+
**Pre-release:** the hosted service is not serving yet. Full docs, the OpenAPI contract, the
|
|
27
|
+
n8n node and Agent Skills: https://github.com/dmilstein-match/hunter-seeker-sdk
|
|
28
|
+
|
|
29
|
+
Apache-2.0.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""hunter-seeker — Python client for the Hunter-Seeker Verdict layer.
|
|
2
|
+
|
|
3
|
+
from hunter_seeker import Client
|
|
4
|
+
hs = Client(api_key="hsk_test_...") # or oauth token
|
|
5
|
+
caps = hs.describe_capabilities()
|
|
6
|
+
run = hs.rank_topk(dataset_id="sample:saas_churn", entity_column="customer_id",
|
|
7
|
+
outcome_column="churned", subject_kind="org")
|
|
8
|
+
v = hs.score_entity(run["model_ref"], {"tenure": 14, "seats": 3}, subject_kind="org")
|
|
9
|
+
hs.verify(v["verdict"], v["signature"]) # -> "valid"
|
|
10
|
+
|
|
11
|
+
Framework adapters: hunter_seeker.langchain, hunter_seeker.crewai. CLI: `hs`.
|
|
12
|
+
"""
|
|
13
|
+
from .client import Client, HunterSeekerError, ProblemDetails
|
|
14
|
+
|
|
15
|
+
__all__ = ["Client", "HunterSeekerError", "ProblemDetails"]
|
|
16
|
+
__version__ = "2.0.0"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""`hs` — the command line.
|
|
2
|
+
|
|
3
|
+
hs init leads.csv propose entity/outcome columns (propose-and-gate), write hs.yaml
|
|
4
|
+
hs rank run hs.yaml (one billed run) and print model_ref + verdict id
|
|
5
|
+
hs score '{"tenure":14}' score one row against the model_ref in hs.yaml
|
|
6
|
+
hs verify v.json s.json keyless verification
|
|
7
|
+
hs sample rank a hosted sample dataset (free) — the five-minute test
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .client import Client, HunterSeekerError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _client() -> Client:
|
|
21
|
+
key = os.environ.get("HS_API_KEY")
|
|
22
|
+
if not key:
|
|
23
|
+
sys.exit("set HS_API_KEY (hsk_test_... reaches the free sample datasets)")
|
|
24
|
+
return Client(api_key=key, base_url=os.environ.get("HS_BASE_URL", "https://hunter-seeker.net/api"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _propose(path: Path) -> dict:
|
|
28
|
+
"""Local first pass: enumerate binary-looking and id-looking columns. The server-side
|
|
29
|
+
/resolve-spec gate is authoritative; this only writes candidates for the human to confirm."""
|
|
30
|
+
with path.open(newline="") as f:
|
|
31
|
+
rows = list(csv.DictReader(f))
|
|
32
|
+
cols = rows[0].keys() if rows else []
|
|
33
|
+
n = len(rows)
|
|
34
|
+
ids, outs = [], []
|
|
35
|
+
for c in cols:
|
|
36
|
+
vals = [r[c] for r in rows if r.get(c) not in (None, "")]
|
|
37
|
+
distinct = set(vals)
|
|
38
|
+
if n and len(distinct) == n:
|
|
39
|
+
ids.append(c)
|
|
40
|
+
low = {v.strip().lower() for v in distinct}
|
|
41
|
+
if low and low <= {"0", "1", "true", "false", "yes", "no", "y", "n"}:
|
|
42
|
+
outs.append(c)
|
|
43
|
+
return {"rows": n, "entity_candidates": ids, "outcome_candidates": outs}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main(argv=None) -> int:
|
|
47
|
+
a = list(argv or sys.argv[1:])
|
|
48
|
+
if not a or a[0] in ("-h", "--help"):
|
|
49
|
+
print(__doc__); return 0
|
|
50
|
+
cmd, rest = a[0], a[1:]
|
|
51
|
+
try:
|
|
52
|
+
if cmd == "init":
|
|
53
|
+
p = Path(rest[0]); prop = _propose(p)
|
|
54
|
+
def pick(kind, cands):
|
|
55
|
+
if len(cands) == 1: return cands[0]
|
|
56
|
+
if not cands: return None
|
|
57
|
+
print(f"{kind}: choose one of {cands}"); return None # ≥2 → surface a choice, never guess
|
|
58
|
+
spec = {"dataset": str(p), "entity_column": pick("entity", prop["entity_candidates"]),
|
|
59
|
+
"outcome_column": pick("outcome", prop["outcome_candidates"]), "subject_kind": "org", "k": 20}
|
|
60
|
+
Path("hs.yaml").write_text("".join(f"{k}: {json.dumps(v)}\n" for k, v in spec.items()))
|
|
61
|
+
print(json.dumps({"proposed": prop, "wrote": "hs.yaml"}, indent=2)); return 0
|
|
62
|
+
hs = _client()
|
|
63
|
+
if cmd == "sample":
|
|
64
|
+
out = hs.rank_topk(dataset_id="sample:saas_churn", entity_column="customer_id", outcome_column="churned", subject_kind="org")
|
|
65
|
+
print(json.dumps({k: out.get(k) for k in ("ranking_ref", "model_ref", "honest_empty")}, indent=2))
|
|
66
|
+
if out.get("verdict"):
|
|
67
|
+
print("verify:", hs.verify(out["verdict"], out["signature"]))
|
|
68
|
+
return 0
|
|
69
|
+
spec = {l.split(":")[0]: json.loads(l.split(":", 1)[1]) for l in Path("hs.yaml").read_text().splitlines() if l.strip()}
|
|
70
|
+
if cmd == "rank":
|
|
71
|
+
csv_text = Path(spec["dataset"]).read_text()
|
|
72
|
+
out = hs.rank_topk(csv=csv_text, entity_column=spec["entity_column"], outcome_column=spec["outcome_column"],
|
|
73
|
+
subject_kind=spec["subject_kind"], k=spec.get("k", 20))
|
|
74
|
+
spec["model_ref"] = out.get("model_ref")
|
|
75
|
+
Path("hs.yaml").write_text("".join(f"{k}: {json.dumps(v)}\n" for k, v in spec.items()))
|
|
76
|
+
print(json.dumps({k: out.get(k) for k in ("ranking_ref", "model_ref", "honest_empty")}, indent=2)); return 0
|
|
77
|
+
if cmd == "score":
|
|
78
|
+
out = hs.score_entity(spec["model_ref"], json.loads(rest[0]), subject_kind=spec["subject_kind"])
|
|
79
|
+
print(json.dumps(out["entity"], indent=2))
|
|
80
|
+
st = hs.verify(out["verdict"], out.get("signature"))
|
|
81
|
+
print("verify:", st + ("" if st == "valid" else " (do not act on an unverified Verdict)")); return 0
|
|
82
|
+
if cmd == "verify":
|
|
83
|
+
v, s = json.load(open(rest[0])), json.load(open(rest[1])); print(hs.verify(v, s)); return 0
|
|
84
|
+
print(__doc__); return 2
|
|
85
|
+
except HunterSeekerError as e:
|
|
86
|
+
print(json.dumps({"error": e.problem.code, "detail": e.problem.detail, "remedy": e.problem.remedy}), file=sys.stderr)
|
|
87
|
+
return 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Dict, Mapping, Optional
|
|
10
|
+
|
|
11
|
+
DEFAULT_BASE = "https://hunter-seeker.net/api"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ProblemDetails:
|
|
16
|
+
status: int
|
|
17
|
+
code: str
|
|
18
|
+
detail: str
|
|
19
|
+
remedy: str
|
|
20
|
+
field: Optional[str]
|
|
21
|
+
request_id: Optional[str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HunterSeekerError(Exception):
|
|
25
|
+
def __init__(self, p: ProblemDetails) -> None:
|
|
26
|
+
super().__init__(f"{p.code}: {p.detail} — {p.remedy}")
|
|
27
|
+
self.problem = p
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def retryable(self) -> bool:
|
|
31
|
+
return self.problem.status in (429, 503)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Client:
|
|
35
|
+
def __init__(self, api_key: Optional[str] = None, *, oauth_token: Optional[str] = None,
|
|
36
|
+
base_url: str = DEFAULT_BASE, timeout: float = 60.0) -> None:
|
|
37
|
+
if not (api_key or oauth_token):
|
|
38
|
+
raise ValueError("provide api_key (hsk_...) or oauth_token")
|
|
39
|
+
self._auth = f"Bearer {oauth_token or api_key}"
|
|
40
|
+
self.base = base_url.rstrip("/")
|
|
41
|
+
self.timeout = timeout
|
|
42
|
+
self.test_mode = bool(api_key and api_key.startswith("hsk_test_"))
|
|
43
|
+
|
|
44
|
+
# -- transport ---------------------------------------------------------- #
|
|
45
|
+
def _call(self, path: str, body: Optional[Mapping[str, Any]] = None, *, method: str = "POST",
|
|
46
|
+
idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
47
|
+
data = json.dumps(body or {}).encode() if method == "POST" else None
|
|
48
|
+
req = urllib.request.Request(self.base + path, data=data, method=method, headers={
|
|
49
|
+
"authorization": self._auth, "content-type": "application/json",
|
|
50
|
+
"user-agent": "hunter-seeker-python/2.0.0",
|
|
51
|
+
**({"idempotency-key": idempotency_key} if idempotency_key else {}),
|
|
52
|
+
})
|
|
53
|
+
try:
|
|
54
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r: # noqa: S310
|
|
55
|
+
return json.load(r)
|
|
56
|
+
except urllib.error.HTTPError as e:
|
|
57
|
+
try:
|
|
58
|
+
prob = json.load(e)
|
|
59
|
+
hs = prob.get("hs") or (prob.get("detail") or {}).get("hs") or {}
|
|
60
|
+
det = prob.get("detail") if isinstance(prob.get("detail"), str) else (prob.get("detail") or {}).get("detail", "")
|
|
61
|
+
except Exception: # noqa: BLE001
|
|
62
|
+
hs, det = {}, e.reason
|
|
63
|
+
raise HunterSeekerError(ProblemDetails(e.code, hs.get("code", "http_error"), det or str(e.reason),
|
|
64
|
+
hs.get("remedy", "see docs"), hs.get("field"),
|
|
65
|
+
e.headers.get("x-request-id"))) from None
|
|
66
|
+
|
|
67
|
+
# -- existing surface --------------------------------------------------- #
|
|
68
|
+
def describe_capabilities(self) -> Dict[str, Any]:
|
|
69
|
+
return self._call("/v1/describe-capabilities")
|
|
70
|
+
|
|
71
|
+
def provide_dataset(self, *, fetch_url: Optional[str] = None, name: Optional[str] = None) -> Dict[str, Any]:
|
|
72
|
+
return self._call("/v1/provide-dataset", {k: v for k, v in {"fetch_url": fetch_url, "name": name}.items() if v})
|
|
73
|
+
|
|
74
|
+
def rank_topk(self, *, entity_column: str, outcome_column: str, subject_kind: str,
|
|
75
|
+
dataset_id: Optional[str] = None, rows: Optional[list] = None, csv: Optional[str] = None,
|
|
76
|
+
fetch_url: Optional[str] = None, k: int = 20, horizon: Optional[str] = None,
|
|
77
|
+
acknowledge_decision_support: bool = False, reading: Optional[Mapping[str, Any]] = None,
|
|
78
|
+
refit_of: Optional[str] = None, idempotency_key: Optional[str] = None,
|
|
79
|
+
wait: bool = True, poll_s: float = 2.0) -> Dict[str, Any]:
|
|
80
|
+
data = {k_: v for k_, v in {"dataset_id": dataset_id, "rows": rows, "csv": csv, "fetch_url": fetch_url}.items() if v}
|
|
81
|
+
body: Dict[str, Any] = {"data": data, "entity_column": entity_column, "outcome_column": outcome_column,
|
|
82
|
+
"subject_kind": subject_kind, "page": {"k": k},
|
|
83
|
+
"acknowledge_decision_support": acknowledge_decision_support}
|
|
84
|
+
if horizon: body["horizon"] = horizon
|
|
85
|
+
if reading: body["reading"] = dict(reading)
|
|
86
|
+
if refit_of: body["refit_of"] = refit_of
|
|
87
|
+
key = idempotency_key or str(uuid.uuid4())
|
|
88
|
+
body["idempotency_key"] = key
|
|
89
|
+
out = self._call("/v1/rank-topk", body, idempotency_key=key)
|
|
90
|
+
while wait and out.get("status") == "pending":
|
|
91
|
+
time.sleep(max(poll_s, out.get("retry_after_ms", 0) / 1000))
|
|
92
|
+
out = self.poll_task(out["task_id"])
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
def poll_task(self, task_id: str) -> Dict[str, Any]:
|
|
96
|
+
return self._call("/v1/poll-task", {"task_id": task_id})
|
|
97
|
+
|
|
98
|
+
def model_quality(self, ranking_ref: str) -> Dict[str, Any]:
|
|
99
|
+
return self._call("/v1/model-quality", {"ranking_ref": ranking_ref})
|
|
100
|
+
|
|
101
|
+
def explain_drivers(self, ranking_ref: str) -> Dict[str, Any]:
|
|
102
|
+
return self._call("/v1/explain-drivers", {"ranking_ref": ranking_ref})
|
|
103
|
+
|
|
104
|
+
def explain_levers(self, ranking_ref: str, entity_ids: list[str]) -> Dict[str, Any]:
|
|
105
|
+
return self._call("/v1/explain-levers", {"ranking_ref": ranking_ref, "entity_ids": entity_ids})
|
|
106
|
+
|
|
107
|
+
def context_brief(self, ranking_ref: str, fmt: str = "json") -> Dict[str, Any]:
|
|
108
|
+
return self._call("/v1/context-brief", {"ranking_ref": ranking_ref, "format": fmt})
|
|
109
|
+
|
|
110
|
+
# -- verdict layer ------------------------------------------------------- #
|
|
111
|
+
def score_entity(self, model_ref: str, row: Mapping[str, Any], *, subject_kind: str,
|
|
112
|
+
entity_id: Optional[str] = None, acknowledge_decision_support: bool = False,
|
|
113
|
+
on_behalf_of: Optional[Mapping[str, str]] = None) -> Dict[str, Any]:
|
|
114
|
+
body = {"model_ref": model_ref, "row": dict(row), "subject_kind": subject_kind,
|
|
115
|
+
"acknowledge_decision_support": acknowledge_decision_support}
|
|
116
|
+
if entity_id: body["entity_id"] = entity_id
|
|
117
|
+
if on_behalf_of: body["on_behalf_of"] = dict(on_behalf_of)
|
|
118
|
+
return self._call("/v1/score-entity", body)
|
|
119
|
+
|
|
120
|
+
def score_batch(self, model_ref: str, rows: list, *, subject_kind: str, entity_column: Optional[str] = None,
|
|
121
|
+
acknowledge_decision_support: bool = False, as_of: Optional[str] = None) -> Dict[str, Any]:
|
|
122
|
+
body = {"model_ref": model_ref, "rows": rows, "subject_kind": subject_kind,
|
|
123
|
+
"acknowledge_decision_support": acknowledge_decision_support}
|
|
124
|
+
if entity_column: body["entity_column"] = entity_column
|
|
125
|
+
if as_of: body["as_of"] = as_of
|
|
126
|
+
return self._call("/v1/score-batch", body)
|
|
127
|
+
|
|
128
|
+
def verify(self, verdict: Mapping[str, Any], signature: Optional[Mapping[str, str]], *,
|
|
129
|
+
offline: bool = True, jwks: Optional[Mapping[str, Any]] = None) -> str:
|
|
130
|
+
"""Offline by default via hs-verify. Pass ``jwks`` to avoid the network entirely.
|
|
131
|
+
|
|
132
|
+
A Verdict with no signature is UNVERIFIABLE and is reported as "invalid_signature":
|
|
133
|
+
production deployments always sign, so a missing signature means a misconfigured or
|
|
134
|
+
non-production server, never a valid decision.
|
|
135
|
+
"""
|
|
136
|
+
if not signature or not signature.get("protected") or not signature.get("signature"):
|
|
137
|
+
return "invalid_signature"
|
|
138
|
+
if offline:
|
|
139
|
+
try:
|
|
140
|
+
from hs_verify import verify as _v # type: ignore
|
|
141
|
+
except ImportError:
|
|
142
|
+
pass
|
|
143
|
+
else:
|
|
144
|
+
# `jwks` makes this genuinely offline. Without it hs_verify fetches the
|
|
145
|
+
# published keys, and an unreachable JWKS RAISES rather than reporting the
|
|
146
|
+
# Verdict as invalid — a network failure is not a forgery.
|
|
147
|
+
return _v(verdict, signature, jwks=jwks) if jwks is not None else _v(verdict, signature)
|
|
148
|
+
return self._call("/v1/verify-verdict", {"verdict": dict(verdict), "signature": dict(signature)})["status"]
|
|
149
|
+
|
|
150
|
+
def report_outcome(self, model_ref: str, outcomes: list[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
151
|
+
return self._call("/v1/report-outcome", {"model_ref": model_ref, "outcomes": [dict(o) for o in outcomes]})
|
|
152
|
+
|
|
153
|
+
def attest_action(self, *, model_ref: str, entity_id: str, lever_token: str, post_value: Any,
|
|
154
|
+
acted_at: str, event_id: Optional[str] = None) -> Dict[str, Any]:
|
|
155
|
+
body = {"model_ref": model_ref, "entity_id": entity_id, "lever_token": lever_token,
|
|
156
|
+
"post_value": post_value, "acted_at": acted_at}
|
|
157
|
+
if event_id: body["event_id"] = event_id
|
|
158
|
+
return self._call("/v1/attest-action", body)
|
|
159
|
+
|
|
160
|
+
def action_evidence(self, model_ref: str) -> Dict[str, Any]:
|
|
161
|
+
return self._call("/v1/action-evidence", {"model_ref": model_ref})
|
|
162
|
+
|
|
163
|
+
def drift_status(self, model_ref: str) -> Dict[str, Any]:
|
|
164
|
+
return self._call("/v1/drift-status", {"model_ref": model_ref})
|
|
165
|
+
|
|
166
|
+
def export_bundle(self, verdict_id: str, entity_id: Optional[str] = None) -> Dict[str, Any]:
|
|
167
|
+
body = {"verdict_id": verdict_id}
|
|
168
|
+
if entity_id: body["entity_id"] = entity_id
|
|
169
|
+
return self._call("/v1/export-bundle", body)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""CrewAI tools. `pip install hunter-seeker[crewai]`. Or point CrewAI at the MCP server:
|
|
2
|
+
Agent(..., mcps=["https://hunter-seeker.net/api/mcp#hs_score_entity"])
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, Type
|
|
7
|
+
|
|
8
|
+
from .client import Client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def score_entity_tool(hs: Client):
|
|
12
|
+
from crewai.tools import BaseTool # type: ignore
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
class Args(BaseModel):
|
|
16
|
+
model_ref: str = Field(description="model_ref from a cleared hs_rank_topk")
|
|
17
|
+
row: Dict[str, Any] = Field(description="one entity's feature values")
|
|
18
|
+
subject_kind: str = Field(description="person | org | object | event | other")
|
|
19
|
+
acknowledge_decision_support: bool = False
|
|
20
|
+
|
|
21
|
+
class HSScoreEntity(BaseTool):
|
|
22
|
+
name: str = "hs_score_entity"
|
|
23
|
+
description: str = ("Score one row against a fitted scorecard; costs one decision. Returns score, band "
|
|
24
|
+
"(act|escalate|refuse), max_autonomy, principal_reasons, and a signed Verdict. Read band; "
|
|
25
|
+
"never reconstruct thresholds. Person-level requires acknowledge_decision_support=True.")
|
|
26
|
+
args_schema: Type[BaseModel] = Args
|
|
27
|
+
|
|
28
|
+
def _run(self, model_ref: str, row: Dict[str, Any], subject_kind: str,
|
|
29
|
+
acknowledge_decision_support: bool = False) -> Dict[str, Any]:
|
|
30
|
+
return hs.score_entity(model_ref, row, subject_kind=subject_kind,
|
|
31
|
+
acknowledge_decision_support=acknowledge_decision_support)
|
|
32
|
+
|
|
33
|
+
return HSScoreEntity()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""LangChain / LangGraph tools. `pip install hunter-seeker[langchain]`.
|
|
2
|
+
|
|
3
|
+
from hunter_seeker.langchain import verdict_tools
|
|
4
|
+
tools = verdict_tools(Client(api_key=...))
|
|
5
|
+
|
|
6
|
+
The approval-gate pattern (LangGraph):
|
|
7
|
+
|
|
8
|
+
def gate(state):
|
|
9
|
+
v = hs.score_entity(state["model_ref"], state["row"], subject_kind="person",
|
|
10
|
+
acknowledge_decision_support=True)
|
|
11
|
+
if v["entity"]["band"] != "act":
|
|
12
|
+
decision = interrupt({"verdict": v["verdict"], "signature": v["signature"],
|
|
13
|
+
"band": v["entity"]["band"], "reasons": v["entity"]["principal_reasons"]})
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
interrupt() re-runs the node on resume: pass the same idempotency_key to rank_topk, and
|
|
17
|
+
score_entity is idempotent, so a resumed node never double-bills.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any, Dict, List
|
|
22
|
+
|
|
23
|
+
from .client import Client
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def verdict_tools(hs: Client) -> List[Any]:
|
|
27
|
+
try:
|
|
28
|
+
from langchain_core.tools import tool
|
|
29
|
+
except ImportError as e: # pragma: no cover
|
|
30
|
+
raise ImportError("pip install langchain-core") from e
|
|
31
|
+
|
|
32
|
+
@tool
|
|
33
|
+
def hs_score_entity(model_ref: str, row: Dict[str, Any], subject_kind: str,
|
|
34
|
+
acknowledge_decision_support: bool = False, entity_id: str | None = None) -> Dict[str, Any]:
|
|
35
|
+
"""Score ONE row against a fitted scorecard (model_ref from a cleared hs_rank_topk). Costs one decision. Returns score, band (act|escalate|refuse), max_autonomy (L0-L4), up to four principal_reasons, and a signed Verdict. Never reconstruct thresholds; read band. person-level requires acknowledge_decision_support=True."""
|
|
36
|
+
return hs.score_entity(model_ref, row, subject_kind=subject_kind, entity_id=entity_id,
|
|
37
|
+
acknowledge_decision_support=acknowledge_decision_support)
|
|
38
|
+
|
|
39
|
+
@tool
|
|
40
|
+
def hs_verify_verdict(verdict: Dict[str, Any], signature: Dict[str, str]) -> str:
|
|
41
|
+
"""Free, keyless. Returns valid | invalid_signature | expired | unknown_key. An expired verdict is re-scored, never reused."""
|
|
42
|
+
return hs.verify(verdict, signature)
|
|
43
|
+
|
|
44
|
+
@tool
|
|
45
|
+
def hs_report_outcome(model_ref: str, entity_id: str, outcome: bool, observed_at: str, event_id: str | None = None) -> Dict[str, Any]:
|
|
46
|
+
"""Free. Report the REAL-WORLD binary outcome you observed for an entity you scored. Idempotent by event_id. Never changes the model."""
|
|
47
|
+
o: Dict[str, Any] = {"entity_id": entity_id, "outcome": outcome, "observed_at": observed_at}
|
|
48
|
+
if event_id: o["event_id"] = event_id
|
|
49
|
+
return hs.report_outcome(model_ref, [o])
|
|
50
|
+
|
|
51
|
+
@tool
|
|
52
|
+
def hs_action_evidence(model_ref: str) -> Dict[str, Any]:
|
|
53
|
+
"""Free. Did acting on this pattern work? Returns holdout and live blocks (never merged); live is null below the statistical floor."""
|
|
54
|
+
return hs.action_evidence(model_ref)
|
|
55
|
+
|
|
56
|
+
@tool
|
|
57
|
+
def hs_drift_status(model_ref: str) -> Dict[str, Any]:
|
|
58
|
+
"""Free. Has the pattern changed since the prior run? recommendation keep | refit | abandon. Never diff two briefs yourself."""
|
|
59
|
+
return hs.drift_status(model_ref)
|
|
60
|
+
|
|
61
|
+
return [hs_score_entity, hs_verify_verdict, hs_report_outcome, hs_action_evidence, hs_drift_status]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hunter-seeker
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Python client and framework tools for the Hunter-Seeker Verdict layer: deterministic, signed, refusable decisions for agents.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/dmilstein-match/hunter-seeker-sdk
|
|
7
|
+
Project-URL: Source, https://github.com/dmilstein-match/hunter-seeker-sdk
|
|
8
|
+
Project-URL: Documentation, https://hunter-seeker.net/docs
|
|
9
|
+
Project-URL: Changelog, https://github.com/dmilstein-match/hunter-seeker-sdk/blob/main/CHANGELOG.md
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: hs-verify>=0.1
|
|
13
|
+
Provides-Extra: langchain
|
|
14
|
+
Requires-Dist: langchain-core>=0.3; extra == "langchain"
|
|
15
|
+
Provides-Extra: crewai
|
|
16
|
+
Requires-Dist: crewai>=0.100; extra == "crewai"
|
|
17
|
+
Requires-Dist: crewai-tools>=0.40; extra == "crewai"
|
|
18
|
+
|
|
19
|
+
# hunter-seeker
|
|
20
|
+
|
|
21
|
+
Python client, `hs` CLI and framework adapters for the **Hunter-Seeker Verdict layer** —
|
|
22
|
+
deterministic, signed, refusable decisions for AI agents.
|
|
23
|
+
|
|
24
|
+
> Agents may generate copy. They may not invent the score.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install hunter-seeker
|
|
28
|
+
export HS_API_KEY=hsk_test_...
|
|
29
|
+
hs sample # ranks a sample dataset and verifies the Verdict
|
|
30
|
+
```
|
|
31
|
+
```python
|
|
32
|
+
from hunter_seeker import Client
|
|
33
|
+
hs = Client()
|
|
34
|
+
out = hs.score_entity(model_ref=ref, row=row, subject_kind="org")
|
|
35
|
+
out["entity"]["band"] # act | escalate | refuse
|
|
36
|
+
hs.verify(out["verdict"], out["signature"]) # valid | invalid_signature | expired | unknown_key
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Extras: `hunter-seeker[langchain]`, `hunter-seeker[crewai]`.
|
|
40
|
+
|
|
41
|
+
The client refuses to call an unsigned Verdict valid — a missing signature reports
|
|
42
|
+
`invalid_signature`, because production engines will not serve one.
|
|
43
|
+
|
|
44
|
+
**Pre-release:** the hosted service is not serving yet. Full docs, the OpenAPI contract, the
|
|
45
|
+
n8n node and Agent Skills: https://github.com/dmilstein-match/hunter-seeker-sdk
|
|
46
|
+
|
|
47
|
+
Apache-2.0.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
hunter_seeker/__init__.py
|
|
4
|
+
hunter_seeker/cli.py
|
|
5
|
+
hunter_seeker/client.py
|
|
6
|
+
hunter_seeker/crewai.py
|
|
7
|
+
hunter_seeker/langchain.py
|
|
8
|
+
hunter_seeker.egg-info/PKG-INFO
|
|
9
|
+
hunter_seeker.egg-info/SOURCES.txt
|
|
10
|
+
hunter_seeker.egg-info/dependency_links.txt
|
|
11
|
+
hunter_seeker.egg-info/entry_points.txt
|
|
12
|
+
hunter_seeker.egg-info/requires.txt
|
|
13
|
+
hunter_seeker.egg-info/top_level.txt
|
|
14
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hunter_seeker
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hunter-seeker"
|
|
3
|
+
version = "2.0.0"
|
|
4
|
+
description = "Python client and framework tools for the Hunter-Seeker Verdict layer: deterministic, signed, refusable decisions for agents."
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = ["hs-verify>=0.1"]
|
|
7
|
+
license = {text = "Apache-2.0"}
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
|
|
10
|
+
[project.urls]
|
|
11
|
+
Homepage = "https://github.com/dmilstein-match/hunter-seeker-sdk"
|
|
12
|
+
Source = "https://github.com/dmilstein-match/hunter-seeker-sdk"
|
|
13
|
+
Documentation = "https://hunter-seeker.net/docs"
|
|
14
|
+
Changelog = "https://github.com/dmilstein-match/hunter-seeker-sdk/blob/main/CHANGELOG.md"
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
langchain = ["langchain-core>=0.3"]
|
|
18
|
+
crewai = ["crewai>=0.100", "crewai-tools>=0.40"]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
hs = "hunter_seeker.cli:main"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import json, threading
|
|
2
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
3
|
+
from hunter_seeker import Client, HunterSeekerError
|
|
4
|
+
|
|
5
|
+
class H(BaseHTTPRequestHandler):
|
|
6
|
+
def log_message(self, *a): pass
|
|
7
|
+
def do_POST(self):
|
|
8
|
+
n = int(self.headers.get("content-length", 0)); body = json.loads(self.rfile.read(n) or b"{}")
|
|
9
|
+
if self.path == "/v1/score-entity":
|
|
10
|
+
out = {"entity": {"entity_id": "a", "score": 0.9, "band": "act", "max_autonomy": "L3", "principal_reasons": []}, "verdict": {"expires_at": "2099-01-01T00:00:00Z"}, "signature": {"protected": "x", "signature": "y"}, "billable_decisions": 1}
|
|
11
|
+
self.send_response(200)
|
|
12
|
+
else:
|
|
13
|
+
out = {"type": "https://hunter-seeker.net/problems/model_ref_expired", "title": "expired", "status": 404, "detail": "gone", "hs": {"code": "model_ref_expired", "remedy": "re-run", "field": "model_ref"}}
|
|
14
|
+
self.send_response(404)
|
|
15
|
+
self.send_header("content-type", "application/json"); self.end_headers(); self.wfile.write(json.dumps(out).encode())
|
|
16
|
+
|
|
17
|
+
def test_client_roundtrip_and_problem_mapping():
|
|
18
|
+
srv = HTTPServer(("127.0.0.1", 0), H); threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
19
|
+
hs = Client(api_key="hsk_test_abc", base_url=f"http://127.0.0.1:{srv.server_port}")
|
|
20
|
+
assert hs.test_mode
|
|
21
|
+
r = hs.score_entity("mr1_x", {"tenure": 1}, subject_kind="org")
|
|
22
|
+
assert r["entity"]["band"] == "act"
|
|
23
|
+
try:
|
|
24
|
+
hs.drift_status("mr1_gone"); assert False
|
|
25
|
+
except HunterSeekerError as e:
|
|
26
|
+
assert e.problem.code == "model_ref_expired" and e.problem.remedy == "re-run" and not e.retryable
|
|
27
|
+
srv.shutdown()
|
|
28
|
+
|
|
29
|
+
def test_init_proposes_and_never_guesses(tmp_path, monkeypatch):
|
|
30
|
+
from hunter_seeker.cli import main
|
|
31
|
+
(tmp_path / "leads.csv").write_text("id,churned,other,tenure\n1,1,1,5\n2,0,0,5\n3,1,1,2\n")
|
|
32
|
+
monkeypatch.chdir(tmp_path)
|
|
33
|
+
assert main(["init", "leads.csv"]) == 0
|
|
34
|
+
spec = (tmp_path / "hs.yaml").read_text()
|
|
35
|
+
assert 'entity_column: "id"' in spec and 'outcome_column: null' in spec # two outcome candidates → a choice, not a guess
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_missing_signature_is_unverifiable_not_valid():
|
|
39
|
+
hs = Client(api_key="hsk_test_abc", base_url="http://127.0.0.1:1")
|
|
40
|
+
assert hs.verify({"expires_at": "2099-01-01T00:00:00Z"}, None) == "invalid_signature"
|
|
41
|
+
assert hs.verify({"expires_at": "2099-01-01T00:00:00Z"}, {}) == "invalid_signature"
|