datagoat 1.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,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: datagoat
3
+ Version: 1.0.0
4
+ Summary: Ask yes/no, score, choice and rank questions about cases. Datagoat answers from what happened to cases like them: deterministic, with reasons, signed.
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://datagoat.io
7
+ Project-URL: Documentation, https://datagoat.io/docs
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Provides-Extra: langchain
11
+ Requires-Dist: langchain-core>=0.3; extra == "langchain"
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == "test"
14
+ Requires-Dist: pydantic; extra == "test"
15
+
16
+ # datagoat
17
+
18
+ Ask yes/no, score, choice and rank questions about cases. Datagoat answers from what happened to
19
+ cases like them: deterministic, with reasons, and a signed Verdict. It refuses rather than guess.
20
+
21
+ ```bash
22
+ pip install datagoat
23
+ datagoat signup # a free test key for the sample datasets
24
+ datagoat sample # ask about sample:saas_churn and verify the answer
25
+ ```
26
+
27
+ ```python
28
+ from datagoat import Client, yesno, score
29
+ dg = Client() # reads DATAGOAT_API_KEY, or the key `datagoat signup` saved
30
+ out = dg.ask({"churn": yesno("churned", outcome_is_desirable=False)},
31
+ dataset_id="sample:saas_churn", entity_column="customer_id",
32
+ subject_kind="org", cases={"ids": ["cust_0001"]})
33
+ ```
34
+
35
+ Docs: https://datagoat.io/docs
@@ -0,0 +1,20 @@
1
+ # datagoat
2
+
3
+ Ask yes/no, score, choice and rank questions about cases. Datagoat answers from what happened to
4
+ cases like them: deterministic, with reasons, and a signed Verdict. It refuses rather than guess.
5
+
6
+ ```bash
7
+ pip install datagoat
8
+ datagoat signup # a free test key for the sample datasets
9
+ datagoat sample # ask about sample:saas_churn and verify the answer
10
+ ```
11
+
12
+ ```python
13
+ from datagoat import Client, yesno, score
14
+ dg = Client() # reads DATAGOAT_API_KEY, or the key `datagoat signup` saved
15
+ out = dg.ask({"churn": yesno("churned", outcome_is_desirable=False)},
16
+ dataset_id="sample:saas_churn", entity_column="customer_id",
17
+ subject_kind="org", cases={"ids": ["cust_0001"]})
18
+ ```
19
+
20
+ Docs: https://datagoat.io/docs
@@ -0,0 +1,17 @@
1
+ """datagoat: ask yes/no, score, choice and rank questions about cases.
2
+
3
+ from datagoat import Client, yesno, score, choice, rank
4
+ dg = Client()
5
+ out = dg.ask({"churn": yesno("churned", outcome_is_desirable=False)},
6
+ dataset_id="sample:saas_churn", entity_column="customer_id",
7
+ subject_kind="org", cases={"ids": ["cust_0001"]})
8
+ out["answers"]["churn"]["cases"][0]["p"] # the chance, with reasons and a signed Verdict
9
+
10
+ A record that is not one row per case takes a shape: events, series, panel, signals or traces.
11
+ """
12
+ from ._version import __version__
13
+ from .client import Client, DatagoatError, Problem, register
14
+ from .questions import choice, events, panel, rank, score, series, signals, traces, yesno
15
+
16
+ __all__ = ["Client", "DatagoatError", "Problem", "register", "yesno", "score", "choice", "rank",
17
+ "events", "series", "panel", "signals", "traces", "__version__"]
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,94 @@
1
+ """`datagoat` on the command line.
2
+
3
+ datagoat signup a free test key for the sample datasets, saved for later calls
4
+ datagoat sample ask about sample:saas_churn and verify every Verdict
5
+ datagoat describe question types, prices and the samples
6
+ datagoat ask QUESTIONS --data DATASET_ID --entity COLUMN [--cases IDS] [--kind org]
7
+ e.g. datagoat ask '{"churn":{"type":"yesno","outcome_column":"churned"}}' \\
8
+ --data sample:saas_churn --entity customer_id --cases cust_0001,cust_0002
9
+ datagoat verify VERDICT.json SIGNATURE.json
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import sys
17
+ from typing import List, Optional
18
+
19
+ from .client import CREDENTIALS, Client, DatagoatError, register, saved_key
20
+ from .questions import score, yesno
21
+
22
+
23
+ def _save(key: str) -> None:
24
+ CREDENTIALS.parent.mkdir(parents=True, exist_ok=True)
25
+ CREDENTIALS.write_text(key + "\n")
26
+ os.chmod(CREDENTIALS, 0o600)
27
+
28
+
29
+ def _print_and_verify(dg: Client, out: dict) -> int:
30
+ print(json.dumps(out, indent=2))
31
+ ok = dg.verify_all(out)
32
+ print("verify:", "valid" if ok else "INVALID (do not act on an unverified Verdict)")
33
+ return 0 if ok else 1
34
+
35
+
36
+ def main(argv: Optional[List[str]] = None) -> int:
37
+ p = argparse.ArgumentParser(prog="datagoat", description="Ask questions about cases.")
38
+ sub = p.add_subparsers(dest="cmd", required=True)
39
+ s = sub.add_parser("signup")
40
+ s.add_argument("--force", action="store_true", help="replace a saved key")
41
+ sub.add_parser("sample")
42
+ sub.add_parser("describe")
43
+ a = sub.add_parser("ask")
44
+ a.add_argument("questions")
45
+ a.add_argument("--data", required=True, help="a dataset_id or sample id")
46
+ a.add_argument("--entity", required=True, help="the column naming each case")
47
+ a.add_argument("--cases", help="comma-separated ids; omit to rank the record")
48
+ a.add_argument("--kind", default="org", choices=["person", "org", "object", "event", "other"])
49
+ v = sub.add_parser("verify")
50
+ v.add_argument("verdict")
51
+ v.add_argument("signature")
52
+ args = p.parse_args(argv)
53
+
54
+ try:
55
+ if args.cmd == "signup":
56
+ if saved_key() and not args.force:
57
+ print(f"A key is already saved in {CREDENTIALS}. Use --force to replace it.")
58
+ return 0
59
+ r = register()
60
+ _save(r["api_key"])
61
+ print(f"Saved a test key to {CREDENTIALS}. It works on the sample datasets only.")
62
+ print("For your own data, create a live key at https://datagoat.io/keys and set DATAGOAT_API_KEY.")
63
+ return 0
64
+ if args.cmd == "describe":
65
+ print(json.dumps(Client(api_key="dgk_test_none").describe(), indent=2))
66
+ return 0
67
+ if args.cmd == "verify":
68
+ with open(args.verdict) as f1, open(args.signature) as f2:
69
+ status = Client(api_key="dgk_test_none").verify(json.load(f1), json.load(f2))
70
+ print(status)
71
+ return 0 if status == "valid" else 1
72
+ dg = Client()
73
+ if args.cmd == "sample":
74
+ out = dg.ask({"churn": yesno("churned", outcome_is_desirable=False),
75
+ "risk": score("churned", outcome_is_desirable=False)},
76
+ dataset_id="sample:saas_churn", entity_column="customer_id",
77
+ subject_kind="org", cases={"ids": ["cust_0001"]})
78
+ return _print_and_verify(dg, out)
79
+ if args.cmd == "ask":
80
+ cases = {"ids": [c.strip() for c in args.cases.split(",") if c.strip()]} if args.cases else None
81
+ out = dg.ask(json.loads(args.questions), dataset_id=args.data, entity_column=args.entity,
82
+ subject_kind=args.kind, cases=cases)
83
+ return _print_and_verify(dg, out)
84
+ except DatagoatError as e:
85
+ print(f"{e.problem.code}: {e.problem.detail}\n{e.problem.remedy}", file=sys.stderr)
86
+ return 1
87
+ except ValueError as e:
88
+ print(str(e), file=sys.stderr)
89
+ return 2
90
+ return 2
91
+
92
+
93
+ if __name__ == "__main__":
94
+ raise SystemExit(main())
@@ -0,0 +1,233 @@
1
+ """The Datagoat client. Standard library only.
2
+
3
+ from datagoat import Client, yesno
4
+ dg = Client() # DATAGOAT_API_KEY, else the key `datagoat signup` saved
5
+ dg.ask({"churn": yesno("churned", outcome_is_desirable=False)},
6
+ dataset_id="sample:saas_churn", entity_column="customer_id",
7
+ subject_kind="org", cases={"ids": ["cust_0001"]})
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+ import uuid
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any, Callable, Dict, Mapping, Optional, Sequence
20
+
21
+ from ._version import __version__
22
+
23
+ DEFAULT_BASE = "https://api.datagoat.io"
24
+ CREDENTIALS = Path(os.environ.get("DATAGOAT_CONFIG_DIR", Path.home() / ".config" / "datagoat")) / "credentials"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Problem:
29
+ status: int
30
+ code: str
31
+ detail: str
32
+ remedy: str
33
+ field: Optional[str] = None
34
+ request_id: Optional[str] = None
35
+
36
+
37
+ class DatagoatError(Exception):
38
+ """A problem the API answered with. Read `problem.code` and `problem.remedy`."""
39
+
40
+ def __init__(self, p: Problem) -> None:
41
+ super().__init__(f"{p.code}: {p.detail} ({p.remedy})")
42
+ self.problem = p
43
+
44
+ @property
45
+ def retryable(self) -> bool:
46
+ return self.problem.status in (429, 502, 503, 504)
47
+
48
+
49
+ def _json_default(o: Any) -> Any:
50
+ import datetime as _dt
51
+ if isinstance(o, _dt.date):
52
+ return o.isoformat()
53
+ raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable")
54
+
55
+
56
+ def saved_key() -> Optional[str]:
57
+ try:
58
+ return CREDENTIALS.read_text().strip() or None
59
+ except OSError:
60
+ return None
61
+
62
+
63
+ def _source(dataset_id, rows, csv, fetch_url, fetch_headers) -> Dict[str, Any]:
64
+ data = {k: v for k, v in {"dataset_id": dataset_id, "rows": rows, "csv": csv, "fetch_url": fetch_url}.items() if v is not None}
65
+ if len(data) != 1:
66
+ raise ValueError("pass exactly one of dataset_id, rows, csv, fetch_url")
67
+ if fetch_headers:
68
+ if "fetch_url" not in data:
69
+ raise ValueError("fetch_headers needs fetch_url")
70
+ data["fetch_headers"] = dict(fetch_headers)
71
+ return data
72
+
73
+
74
+ class Client:
75
+ #: The longest Datagoat keeps a first fit running. `ask` never waits longer than this.
76
+ RUN_TIMEOUT_S = 900.0
77
+
78
+ def __init__(self, api_key: Optional[str] = None, *, base_url: Optional[str] = None, timeout: float = 320.0) -> None:
79
+ key = api_key or os.environ.get("DATAGOAT_API_KEY") or saved_key()
80
+ if not key:
81
+ raise ValueError("no API key: pass api_key, set DATAGOAT_API_KEY, or run `datagoat signup`")
82
+ self._auth = f"Bearer {key}"
83
+ self.base = (base_url or os.environ.get("DATAGOAT_BASE_URL") or DEFAULT_BASE).rstrip("/")
84
+ self.timeout = timeout
85
+ self.test_mode = key.startswith("dgk_test_")
86
+
87
+ # -- transport ------------------------------------------------------------ #
88
+ @staticmethod
89
+ def _request(url: str, body: Optional[Mapping[str, Any]], headers: Mapping[str, str], timeout: float) -> Dict[str, Any]:
90
+ data = json.dumps(body or {}, default=_json_default, allow_nan=False).encode()
91
+ req = urllib.request.Request(url, data=data, method="POST", headers={
92
+ "content-type": "application/json", "user-agent": f"datagoat-python/{__version__}", **headers})
93
+ try:
94
+ with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 - fixed https base
95
+ return json.load(r)
96
+ except urllib.error.HTTPError as e:
97
+ try:
98
+ p = json.load(e)
99
+ except Exception: # noqa: BLE001
100
+ p = {}
101
+ raise DatagoatError(Problem(e.code, p.get("code", "http_error"), p.get("detail", str(e.reason)),
102
+ p.get("remedy", "see https://datagoat.io/docs"), p.get("field"),
103
+ p.get("request_id") or e.headers.get("x-request-id"))) from None
104
+
105
+ def _call(self, op: str, body: Optional[Mapping[str, Any]] = None, *, keyless: bool = False) -> Dict[str, Any]:
106
+ return self._request(f"{self.base}/v1/{op}", body, {} if keyless else {"authorization": self._auth}, self.timeout)
107
+
108
+ def _follow(self, out: Dict[str, Any], *, wait: bool, timeout_s: Optional[float],
109
+ on_progress: Optional[Callable[[Dict[str, Any]], None]]) -> Dict[str, Any]:
110
+ """Follow a pending task to its answer, bounded. Never re-submits: that would fit twice."""
111
+ deadline = time.monotonic() + (self.RUN_TIMEOUT_S if timeout_s is None else timeout_s)
112
+ while wait and out.get("status") == "pending":
113
+ if on_progress is not None:
114
+ on_progress(out)
115
+ task_id = out["task_id"]
116
+ if time.monotonic() >= deadline:
117
+ raise DatagoatError(Problem(504, "poll_timeout", "the task is still running",
118
+ f"poll({task_id!r}) later; do not re-submit (that would fit twice)"))
119
+ time.sleep(max(0.5, out.get("retry_after_ms", 2000) / 1000))
120
+ out = self.poll(task_id)
121
+ return out
122
+
123
+ # -- the one call ----------------------------------------------------------- #
124
+ def ask(self, questions: Mapping[str, Mapping[str, Any]], *, entity_column: str, subject_kind: str,
125
+ dataset_id: Optional[str] = None, rows: Optional[Sequence[Mapping[str, Any]]] = None,
126
+ csv: Optional[str] = None, fetch_url: Optional[str] = None, fetch_headers: Optional[Mapping[str, str]] = None,
127
+ cases: Optional[Mapping[str, Any]] = None, time_column: Optional[str] = None,
128
+ shape: Optional[Mapping[str, Any]] = None, band: bool = False,
129
+ acknowledge_decision_support: bool = False, idempotency_key: Optional[str] = None,
130
+ wait: bool = True, timeout_s: Optional[float] = None,
131
+ on_progress: Optional[Callable[[Dict[str, Any]], None]] = None) -> Dict[str, Any]:
132
+ """Ask typed questions about cases, answered from a record of past outcomes.
133
+
134
+ Build questions with `yesno`, `score`, `choice`, `rank`. `cases` is {"ids": [...]} or
135
+ {"rows": [...]}; omit it to rank the whole record. When the record is not one row per case,
136
+ pass `shape` (build it with `events`, `series`, `panel`, `signals` or `traces`) and
137
+ `time_column`. Each answer's `state` is answered, refused or not_yet; a refusal is an
138
+ answer, and retrying returns the same one.
139
+ """
140
+ if not questions:
141
+ raise ValueError("ask needs at least one question")
142
+ for name, q in questions.items():
143
+ if not isinstance(q, Mapping) or "type" not in q:
144
+ raise ValueError(f"question {name!r} needs a type: yesno | score | choice | rank")
145
+ body: Dict[str, Any] = {
146
+ "data": _source(dataset_id, list(rows) if rows is not None else None, csv, fetch_url, fetch_headers),
147
+ "entity_column": entity_column, "subject_kind": subject_kind,
148
+ "questions": {n: dict(q) for n, q in questions.items()},
149
+ "idempotency_key": idempotency_key or str(uuid.uuid4()),
150
+ }
151
+ if cases is not None:
152
+ body["cases"] = dict(cases)
153
+ if time_column:
154
+ body["time_column"] = time_column
155
+ if shape is not None:
156
+ body["shape"] = dict(shape)
157
+ if band:
158
+ body["band"] = True
159
+ if acknowledge_decision_support:
160
+ body["acknowledge_decision_support"] = True
161
+ return self._follow(self._call("ask", body), wait=wait, timeout_s=timeout_s, on_progress=on_progress)
162
+
163
+ # -- supporting operations ----------------------------------------------------- #
164
+ def add_dataset(self, *, rows: Optional[Sequence[Mapping[str, Any]]] = None, csv: Optional[str] = None,
165
+ fetch_url: Optional[str] = None, fetch_headers: Optional[Mapping[str, str]] = None,
166
+ upload: bool = False, dataset_id: Optional[str] = None, filename: Optional[str] = None) -> Dict[str, Any]:
167
+ """Store a table once and ask about it by dataset_id. Pass dataset_id with rows to append a piece."""
168
+ body = {k: v for k, v in {"rows": list(rows) if rows is not None else None, "csv": csv, "fetch_url": fetch_url,
169
+ "fetch_headers": dict(fetch_headers) if fetch_headers else None,
170
+ "upload": True if upload else None, "dataset_id": dataset_id, "filename": filename}.items() if v is not None}
171
+ return self._call("add-dataset", body)
172
+
173
+ def upload_rows(self, rows: Sequence[Mapping[str, Any]], *, chunk_size: int = 5000) -> str:
174
+ """Send a large table in pieces and return its dataset_id."""
175
+ rows = list(rows)
176
+ if not rows:
177
+ raise ValueError("no rows")
178
+ first = self.add_dataset(rows=rows[:chunk_size])
179
+ for i in range(chunk_size, len(rows), chunk_size):
180
+ self.add_dataset(dataset_id=first["dataset_id"], rows=rows[i:i + chunk_size])
181
+ return first["dataset_id"]
182
+
183
+ def upload_file(self, path: str) -> str:
184
+ """Upload a CSV file of any size through a presigned URL and return its dataset_id."""
185
+ d = self.add_dataset(upload=True, filename=os.path.basename(path))
186
+ with open(path, "rb") as f:
187
+ req = urllib.request.Request(d["upload_url"], data=f.read(), method="PUT", headers={"content-type": "text/csv"})
188
+ with urllib.request.urlopen(req, timeout=self.timeout): # noqa: S310 - presigned URL we were given
189
+ pass
190
+ return d["dataset_id"]
191
+
192
+ def delete_dataset(self, dataset_id: str) -> Dict[str, Any]:
193
+ """Delete a stored dataset now. Otherwise it is deleted 24 hours after its last use."""
194
+ return self._call("delete-dataset", {"dataset_id": dataset_id})
195
+
196
+ def poll(self, task_id: str) -> Dict[str, Any]:
197
+ return self._call("poll", {"task_id": task_id})
198
+
199
+ def preflight(self, dataset_id: str, *, outcome_column: Optional[str] = None,
200
+ predictors: Optional[Sequence[str]] = None) -> Dict[str, Any]:
201
+ body: Dict[str, Any] = {"dataset_id": dataset_id}
202
+ if outcome_column:
203
+ body["outcome_column"] = outcome_column
204
+ if predictors:
205
+ body["predictors"] = list(predictors)
206
+ return self._call("preflight", body)
207
+
208
+ def report_outcomes(self, model_ref: str, outcomes: Sequence[Mapping[str, Any]]) -> Dict[str, Any]:
209
+ """Record what really happened: [{entity_id, outcome, observed_at, event_id?}]."""
210
+ return self._call("report-outcomes", {"model_ref": model_ref, "outcomes": [dict(o) for o in outcomes]})
211
+
212
+ def drift(self, model_ref: str) -> Dict[str, Any]:
213
+ return self._call("drift", {"model_ref": model_ref})
214
+
215
+ def verify(self, verdict: Mapping[str, Any], signature: Optional[Mapping[str, Any]]) -> str:
216
+ """valid | invalid_signature | expired | unknown_key. Needs no key. Never act on anything but valid."""
217
+ if not signature:
218
+ return "invalid_signature"
219
+ return self._call("verify", {"verdict": dict(verdict), "signature": dict(signature)}, keyless=True)["status"]
220
+
221
+ def verify_all(self, answer: Mapping[str, Any]) -> bool:
222
+ """True when every Verdict in an ask answer is valid."""
223
+ return all(self.verify(v["verdict"], v.get("signature")) == "valid"
224
+ for a in (answer.get("answers") or {}).values() for v in a.get("verdicts", []))
225
+
226
+ def describe(self) -> Dict[str, Any]:
227
+ return self._call("describe", {}, keyless=True)
228
+
229
+
230
+ def register(base_url: Optional[str] = None, timeout: float = 30.0) -> Dict[str, Any]:
231
+ """Get a free test key (sample datasets only). No account, no key needed."""
232
+ base = (base_url or os.environ.get("DATAGOAT_BASE_URL") or DEFAULT_BASE).rstrip("/")
233
+ return Client._request(f"{base}/v1/agents/register", {}, {}, timeout)
@@ -0,0 +1,37 @@
1
+ """LangChain tools: `from datagoat.langchain import datagoat_tools` then give them to an agent."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from .client import Client
7
+
8
+
9
+ def datagoat_tools(dg: Client) -> List[Any]:
10
+ from langchain_core.tools import tool
11
+
12
+ @tool
13
+ def dg_ask(questions: Dict[str, Any], entity_column: str, subject_kind: str, dataset_id: Optional[str] = None,
14
+ rows: Optional[List[Dict[str, Any]]] = None, cases: Optional[Dict[str, Any]] = None,
15
+ acknowledge_decision_support: bool = False) -> Dict[str, Any]:
16
+ """Ask yes/no, score, choice and rank questions about cases, answered from a table of past outcomes.
17
+ Read answers[name].state first: answered, refused (do not retry) or not_yet. choice needs
18
+ outcome_is_desirable. Never state a number the answer does not contain."""
19
+ return dg.ask(questions, entity_column=entity_column, subject_kind=subject_kind, dataset_id=dataset_id,
20
+ rows=rows, cases=cases, acknowledge_decision_support=acknowledge_decision_support)
21
+
22
+ @tool
23
+ def dg_verify(verdict: Dict[str, Any], signature: Dict[str, Any]) -> str:
24
+ """Check a Verdict is genuine and unaltered: valid | invalid_signature | expired | unknown_key."""
25
+ return dg.verify(verdict, signature)
26
+
27
+ @tool
28
+ def dg_report_outcomes(model_ref: str, outcomes: List[Dict[str, Any]]) -> Dict[str, Any]:
29
+ """Record what really happened for cases answered under model_ref: [{entity_id, outcome, observed_at, event_id}]."""
30
+ return dg.report_outcomes(model_ref, outcomes)
31
+
32
+ @tool
33
+ def dg_describe() -> Dict[str, Any]:
34
+ """Question types, prices, limits and the free sample datasets with ready-to-run asks."""
35
+ return dg.describe()
36
+
37
+ return [dg_ask, dg_verify, dg_report_outcomes, dg_describe]
@@ -0,0 +1,112 @@
1
+ """Question and shape builders for `Client.ask`, as plain dicts.
2
+
3
+ Each builder sends only what you state. `outcome_is_desirable` is never filled in for you: it says
4
+ whether the outcome is one you want, and a default would decide that for you. It is optional
5
+ everywhere except `choice`, where "the best option" means nothing without it.
6
+
7
+ yesno the chance the outcome happens for each case
8
+ score that chance as a level: unlikely | possible | likely | very_likely by default
9
+ choice the best option: the likeliest to give an outcome you want, the least likely to give
10
+ one you avoid; each answer also carries most_likely and least_likely
11
+ rank cases in order of the chance
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Dict, Mapping, Optional, Sequence
16
+
17
+
18
+ def _stated(**kw: Any) -> Dict[str, Any]:
19
+ return {k: (list(v) if isinstance(v, tuple) else v) for k, v in kw.items() if v is not None}
20
+
21
+
22
+ def yesno(outcome_column: str, *, outcome_is_desirable: Optional[bool] = None,
23
+ positive_values: Optional[Sequence[str]] = None, refit_of: Optional[str] = None) -> Dict[str, Any]:
24
+ """The chance `outcome_column` comes out yes for each case."""
25
+ return {"type": "yesno", **_stated(outcome_column=outcome_column, outcome_is_desirable=outcome_is_desirable,
26
+ positive_values=positive_values, refit_of=refit_of)}
27
+
28
+
29
+ def score(outcome_column: str, *, levels: Optional[Sequence[str]] = None, cuts: Optional[Sequence[float]] = None,
30
+ outcome_is_desirable: Optional[bool] = None, positive_values: Optional[Sequence[str]] = None,
31
+ refit_of: Optional[str] = None) -> Dict[str, Any]:
32
+ """The chance as a named level. `levels` lowest first; `cuts` one fewer, ascending, in (0, 1).
33
+ Omit both for unlikely < 0.25 <= possible < 0.50 <= likely < 0.75 <= very_likely."""
34
+ return {"type": "score", **_stated(outcome_column=outcome_column, levels=levels, cuts=cuts,
35
+ outcome_is_desirable=outcome_is_desirable,
36
+ positive_values=positive_values, refit_of=refit_of)}
37
+
38
+
39
+ def choice(*, outcome_is_desirable: bool, option_column: Optional[str] = None, options: Optional[Sequence[str]] = None,
40
+ outcome_column: Optional[str] = None, option_outcomes: Optional[Mapping[str, str]] = None,
41
+ positive_values: Optional[Sequence[str]] = None) -> Dict[str, Any]:
42
+ """The best option for each case. Two forms:
43
+
44
+ - `option_column` + `options` + `outcome_column`: which value of a column you control (a
45
+ contract, a team, a channel) is best for this case. Refused as `option_not_in_pattern` when
46
+ the record shows the column makes no difference.
47
+ - `option_outcomes`: {option: outcome_column}, one learned outcome per option.
48
+
49
+ `outcome_is_desirable` is required: the best option is the likeliest to give an outcome you
50
+ want and the least likely to give one you avoid."""
51
+ if not isinstance(outcome_is_desirable, bool):
52
+ raise TypeError("choice needs outcome_is_desirable=True or False")
53
+ if (option_outcomes is None) == (option_column is None):
54
+ raise ValueError("choice takes EITHER option_column + options + outcome_column, OR option_outcomes")
55
+ return {"type": "choice", **_stated(option_column=option_column, options=options,
56
+ outcome_column=outcome_column,
57
+ option_outcomes=dict(option_outcomes) if option_outcomes else None,
58
+ outcome_is_desirable=outcome_is_desirable,
59
+ positive_values=positive_values)}
60
+
61
+
62
+ def rank(outcome_column: str, *, top_k: Optional[int] = None, outcome_is_desirable: Optional[bool] = None,
63
+ positive_values: Optional[Sequence[str]] = None, refit_of: Optional[str] = None) -> Dict[str, Any]:
64
+ """The cases (or, with no cases, the whole record) in order of the chance, highest first."""
65
+ return {"type": "rank", **_stated(outcome_column=outcome_column, top_k=top_k,
66
+ outcome_is_desirable=outcome_is_desirable,
67
+ positive_values=positive_values, refit_of=refit_of)}
68
+
69
+
70
+ # -- record shapes ------------------------------------------------------------------ #
71
+ # Pass one of these as `shape=` (with `time_column=`) when the record is not one row per case.
72
+
73
+ def _shape(kind: str, **fields: Any) -> Dict[str, Any]:
74
+ return {"kind": kind, **{k: v for k, v in fields.items() if v is not None}}
75
+
76
+
77
+ def events(*, label: Mapping[str, Any], event_column: Optional[str] = None, value_columns: Optional[Sequence[str]] = None,
78
+ horizon_days: Optional[int] = None, lookback_days: Optional[Sequence[int]] = None, as_of: Optional[str] = None) -> Dict[str, Any]:
79
+ """An event log. label is {"lapsed": True} (outcome lapsed_{horizon_days}d) or {"name": ..., "when": predicate}
80
+ (outcome {name}_next_{horizon_days}d)."""
81
+ return _shape("events", label=dict(label), event_column=event_column, value_columns=list(value_columns) if value_columns else None,
82
+ horizon_days=horizon_days, lookback_days=list(lookback_days) if lookback_days else None, as_of=as_of)
83
+
84
+
85
+ def series(*, value_columns: Sequence[str], windows: Sequence[int]) -> Dict[str, Any]:
86
+ """A time series with an outcome on each row. windows: [3, 6], [7, 30] or [4, 12] periods."""
87
+ return _shape("series", value_columns=list(value_columns), windows=list(windows))
88
+
89
+
90
+ def panel(*, trend_of: Optional[str] = None, window_of: Optional[str] = None, direction: Optional[str] = None,
91
+ alpha: Optional[float] = None, periods: Optional[int] = None, agg: Optional[str] = None,
92
+ min_history: Optional[int] = None) -> Dict[str, Any]:
93
+ """Periods per case. The outcome is a significant trend in `trend_of`, or `window_of` over the final periods."""
94
+ if (trend_of is None) == (window_of is None):
95
+ raise ValueError("a panel reads its outcome from trend_of OR window_of")
96
+ label = ({"trend_of": trend_of, "direction": direction, "alpha": alpha} if trend_of
97
+ else {"window_of": window_of, "periods": periods, "agg": agg})
98
+ return _shape("panel", label={k: v for k, v in label.items() if v is not None}, min_history=min_history)
99
+
100
+
101
+ def signals(*, signal_columns: Sequence[str], windows: Sequence[int], snapshot_every: str, horizon: int,
102
+ event_column: Optional[str] = None, event_start_column: Optional[str] = None, event_end_column: Optional[str] = None,
103
+ min_history: Optional[int] = None, as_of: Optional[str] = None) -> Dict[str, Any]:
104
+ """Sensor readings with event intervals in the same table. The outcome: an event starts within `horizon` snapshots."""
105
+ return _shape("signals", signal_columns=list(signal_columns), windows=list(windows), snapshot_every=snapshot_every, horizon=horizon,
106
+ event_column=event_column, event_start_column=event_start_column, event_end_column=event_end_column,
107
+ min_history=min_history, as_of=as_of)
108
+
109
+
110
+ def traces(*, agent_column: Optional[str] = None, task_column: Optional[str] = None, tool_column: Optional[str] = None) -> Dict[str, Any]:
111
+ """A log of runs, one row per run, with a yes/no outcome column."""
112
+ return _shape("traces", agent_column=agent_column, task_column=task_column, tool_column=tool_column)
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: datagoat
3
+ Version: 1.0.0
4
+ Summary: Ask yes/no, score, choice and rank questions about cases. Datagoat answers from what happened to cases like them: deterministic, with reasons, signed.
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://datagoat.io
7
+ Project-URL: Documentation, https://datagoat.io/docs
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Provides-Extra: langchain
11
+ Requires-Dist: langchain-core>=0.3; extra == "langchain"
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == "test"
14
+ Requires-Dist: pydantic; extra == "test"
15
+
16
+ # datagoat
17
+
18
+ Ask yes/no, score, choice and rank questions about cases. Datagoat answers from what happened to
19
+ cases like them: deterministic, with reasons, and a signed Verdict. It refuses rather than guess.
20
+
21
+ ```bash
22
+ pip install datagoat
23
+ datagoat signup # a free test key for the sample datasets
24
+ datagoat sample # ask about sample:saas_churn and verify the answer
25
+ ```
26
+
27
+ ```python
28
+ from datagoat import Client, yesno, score
29
+ dg = Client() # reads DATAGOAT_API_KEY, or the key `datagoat signup` saved
30
+ out = dg.ask({"churn": yesno("churned", outcome_is_desirable=False)},
31
+ dataset_id="sample:saas_churn", entity_column="customer_id",
32
+ subject_kind="org", cases={"ids": ["cust_0001"]})
33
+ ```
34
+
35
+ Docs: https://datagoat.io/docs
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ datagoat/__init__.py
4
+ datagoat/_version.py
5
+ datagoat/cli.py
6
+ datagoat/client.py
7
+ datagoat/langchain.py
8
+ datagoat/questions.py
9
+ datagoat.egg-info/PKG-INFO
10
+ datagoat.egg-info/SOURCES.txt
11
+ datagoat.egg-info/dependency_links.txt
12
+ datagoat.egg-info/entry_points.txt
13
+ datagoat.egg-info/requires.txt
14
+ datagoat.egg-info/top_level.txt
15
+ tests/test_client.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ datagoat = datagoat.cli:main
@@ -0,0 +1,7 @@
1
+
2
+ [langchain]
3
+ langchain-core>=0.3
4
+
5
+ [test]
6
+ pytest
7
+ pydantic
@@ -0,0 +1 @@
1
+ datagoat
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "datagoat"
3
+ version = "1.0.0"
4
+ description = "Ask yes/no, score, choice and rank questions about cases. Datagoat answers from what happened to cases like them: deterministic, with reasons, signed."
5
+ requires-python = ">=3.9"
6
+ dependencies = []
7
+ license = {text = "Apache-2.0"}
8
+ readme = "README.md"
9
+
10
+ [project.urls]
11
+ Homepage = "https://datagoat.io"
12
+ Documentation = "https://datagoat.io/docs"
13
+
14
+ [project.optional-dependencies]
15
+ langchain = ["langchain-core>=0.3"]
16
+ test = ["pytest", "pydantic"]
17
+
18
+ [project.scripts]
19
+ datagoat = "datagoat.cli:main"
20
+
21
+ [build-system]
22
+ requires = ["setuptools>=68"]
23
+ build-backend = "setuptools.build_meta"
24
+
25
+ [tool.setuptools.packages.find]
26
+ include = ["datagoat*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,167 @@
1
+ """What the client puts on the wire, and how it waits."""
2
+ import itertools
3
+ import json
4
+ import threading
5
+ from http.server import BaseHTTPRequestHandler, HTTPServer
6
+
7
+ import pytest
8
+
9
+ from datagoat import Client, DatagoatError, choice, rank, score, yesno
10
+ from datagoat import cli
11
+
12
+ SEEN = []
13
+
14
+
15
+ def serve(script, status=200):
16
+ it = iter(script)
17
+
18
+ class H(BaseHTTPRequestHandler):
19
+ def log_message(self, *a):
20
+ pass
21
+
22
+ def do_POST(self):
23
+ n = int(self.headers.get("content-length", 0))
24
+ SEEN.append((self.path, json.loads(self.rfile.read(n) or b"{}"), self.headers.get("authorization")))
25
+ body, code = next(it), status
26
+ if isinstance(body, tuple):
27
+ body, code = body
28
+ self.send_response(code)
29
+ self.send_header("content-type", "application/json")
30
+ self.end_headers()
31
+ self.wfile.write(json.dumps(body).encode())
32
+
33
+ srv = HTTPServer(("127.0.0.1", 0), H)
34
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
35
+ return srv, f"http://127.0.0.1:{srv.server_port}"
36
+
37
+
38
+ DONE = {"status": "done", "task_id": "tk_1", "answers": {"churn": {"type": "yesno", "state": "answered", "verdicts": []}}}
39
+
40
+
41
+ def setup_function(_):
42
+ SEEN.clear()
43
+
44
+
45
+ def test_ask_body_is_the_public_contract():
46
+ srv, url = serve([DONE])
47
+ out = Client(api_key="dgk_test_x", base_url=url).ask(
48
+ {"churn": yesno("churned", outcome_is_desirable=False)}, dataset_id="sample:saas_churn",
49
+ entity_column="customer_id", subject_kind="org", cases={"ids": ["cust_0001"]})
50
+ path, body, auth = SEEN[-1]
51
+ assert path == "/v1/ask" and out == DONE and auth == "Bearer dgk_test_x"
52
+ assert body["data"] == {"dataset_id": "sample:saas_churn"}
53
+ assert body["questions"] == {"churn": {"type": "yesno", "outcome_column": "churned", "outcome_is_desirable": False}}
54
+ assert body["idempotency_key"]
55
+ for k in ("band", "time_column", "acknowledge_decision_support"):
56
+ assert k not in body
57
+ srv.shutdown()
58
+
59
+
60
+ def test_builders_send_only_what_is_stated_and_choice_demands_polarity():
61
+ assert yesno("y") == {"type": "yesno", "outcome_column": "y"}
62
+ assert "outcome_is_desirable" not in score("y") and "outcome_is_desirable" not in rank("y")
63
+ assert score("y", levels=("lo", "hi"), cuts=(0.4,))["levels"] == ["lo", "hi"]
64
+ c = choice(option_column="contract", options=["a", "b"], outcome_column="churned", outcome_is_desirable=False)
65
+ assert c["outcome_is_desirable"] is False
66
+ with pytest.raises(TypeError):
67
+ choice(option_column="contract", options=["a", "b"], outcome_column="churned") # type: ignore[call-arg]
68
+ with pytest.raises(ValueError):
69
+ choice(outcome_is_desirable=True)
70
+
71
+
72
+ def test_a_pending_fit_is_followed_and_bounded():
73
+ pending = {"status": "pending", "task_id": "tk_9", "retry_after_ms": 0}
74
+ srv, url = serve([pending, pending, DONE])
75
+ out = Client(api_key="dgk_test_x", base_url=url).ask({"q": yesno("y")}, rows=[{"a": 1}], entity_column="a",
76
+ subject_kind="org", cases={"ids": ["1"]})
77
+ assert out == DONE
78
+ assert [p for p, _, _ in SEEN] == ["/v1/ask", "/v1/poll", "/v1/poll"]
79
+ assert SEEN[1][1] == {"task_id": "tk_9"}
80
+ srv.shutdown()
81
+ srv, url = serve(itertools.repeat(pending))
82
+ with pytest.raises(DatagoatError) as e:
83
+ Client(api_key="dgk_test_x", base_url=url).ask({"q": yesno("y")}, rows=[{"a": 1}], entity_column="a",
84
+ subject_kind="org", cases={"ids": ["1"]}, timeout_s=0.1)
85
+ assert e.value.problem.code == "poll_timeout" and "do not re-submit" in e.value.problem.remedy
86
+ srv.shutdown()
87
+
88
+
89
+ def test_problems_are_raised_with_code_remedy_and_field():
90
+ prob = {"type": "https://datagoat.io/problems/unknown_case_id", "title": "t", "status": 422, "detail": "not in the record",
91
+ "code": "unknown_case_id", "remedy": "send ids that appear", "field": "cases.ids", "request_id": "r1"}
92
+ srv, url = serve([(prob, 422)])
93
+ with pytest.raises(DatagoatError) as e:
94
+ Client(api_key="dgk_live_x", base_url=url).ask({"q": yesno("y")}, dataset_id="ds_x", entity_column="a",
95
+ subject_kind="org", cases={"ids": ["nope"]})
96
+ assert (e.value.problem.code, e.value.problem.field, e.value.problem.request_id) == ("unknown_case_id", "cases.ids", "r1")
97
+ assert not e.value.retryable
98
+ srv.shutdown()
99
+
100
+
101
+ def test_verify_sends_no_key(monkeypatch):
102
+ srv, url = serve([{"status": "valid"}])
103
+ assert Client(api_key="dgk_live_secret", base_url=url).verify({"verdict_id": "v", "kind": "k"}, {"protected": "p", "signature": "s"}) == "valid"
104
+ assert SEEN[-1][2] is None, "verification is keyless: the key is never sent"
105
+ srv.shutdown()
106
+
107
+
108
+ def test_local_mistakes_fail_before_any_request():
109
+ dg = Client(api_key="dgk_test_x", base_url="http://127.0.0.1:9")
110
+ with pytest.raises(ValueError):
111
+ dg.ask({}, dataset_id="sample:saas_churn", entity_column="a", subject_kind="org")
112
+ with pytest.raises(ValueError):
113
+ dg.ask({"q": yesno("y")}, dataset_id="x", rows=[{"a": 1}], entity_column="a", subject_kind="org")
114
+ with pytest.raises(ValueError):
115
+ dg.ask({"q": {"outcome_column": "y"}}, dataset_id="x", entity_column="a", subject_kind="org")
116
+
117
+
118
+ def test_the_key_comes_from_env_then_the_saved_file(monkeypatch, tmp_path):
119
+ import datagoat.client as c
120
+ monkeypatch.setattr(c, "CREDENTIALS", tmp_path / "credentials")
121
+ monkeypatch.delenv("DATAGOAT_API_KEY", raising=False)
122
+ with pytest.raises(ValueError, match="datagoat signup"):
123
+ Client()
124
+ (tmp_path / "credentials").write_text("dgk_test_saved\n")
125
+ assert Client().test_mode
126
+ monkeypatch.setenv("DATAGOAT_API_KEY", "dgk_live_env")
127
+ assert not Client().test_mode
128
+
129
+
130
+ def test_cli_sample_asks_and_verifies(monkeypatch, capsys):
131
+ calls = []
132
+
133
+ class Fake:
134
+ def ask(self, questions, **kw):
135
+ calls.append((questions, kw))
136
+ return {"status": "done", "answers": {"churn": {"state": "answered", "verdicts": [{"verdict": {}, "signature": {}}]}}}
137
+
138
+ def verify_all(self, out):
139
+ return True
140
+
141
+ monkeypatch.setattr(cli, "Client", lambda *a, **k: Fake())
142
+ assert cli.main(["sample"]) == 0
143
+ (q, kw), = calls
144
+ assert kw["dataset_id"] == "sample:saas_churn" and q["churn"]["outcome_is_desirable"] is False
145
+ assert "verify: valid" in capsys.readouterr().out
146
+
147
+
148
+ def test_shapes_are_sent_as_stated_and_delete_dataset_calls_its_route():
149
+ from datagoat import events, panel, series, signals, traces
150
+ SEEN.clear()
151
+ srv, base = serve([DONE, {"dataset_id": "ds_x", "deleted": True}])
152
+ dg = Client("dgk_live_x", base_url=base)
153
+ dg.ask({"quiet": yesno("lapsed_90d", outcome_is_desirable=False)}, dataset_id="sample:customer_events",
154
+ entity_column="customer_id", subject_kind="org", time_column="date", cases={"ids": ["cust_0001"]},
155
+ shape=events(label={"lapsed": True}, event_column="event", horizon_days=90))
156
+ body = SEEN[0][1]
157
+ assert body["shape"] == {"kind": "events", "label": {"lapsed": True}, "event_column": "event", "horizon_days": 90}
158
+ assert body["time_column"] == "date"
159
+ assert dg.delete_dataset("ds_x") == {"dataset_id": "ds_x", "deleted": True}
160
+ assert SEEN[1][0] == "/v1/delete-dataset"
161
+ assert series(value_columns=["sales"], windows=[4, 12]) == {"kind": "series", "value_columns": ["sales"], "windows": [4, 12]}
162
+ assert panel(trend_of="usage", direction="down") == {"kind": "panel", "label": {"trend_of": "usage", "direction": "down"}}
163
+ assert signals(signal_columns=["v"], windows=[1, 3, 7], snapshot_every="1d", horizon=3)["kind"] == "signals"
164
+ assert traces(tool_column="tool") == {"kind": "traces", "tool_column": "tool"}
165
+ with pytest.raises(ValueError):
166
+ panel()
167
+ srv.shutdown()