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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ hs = hunter_seeker.cli:main
@@ -0,0 +1,8 @@
1
+ hs-verify>=0.1
2
+
3
+ [crewai]
4
+ crewai>=0.100
5
+ crewai-tools>=0.40
6
+
7
+ [langchain]
8
+ langchain-core>=0.3
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"