datagoat-decide 0.2.0__py3-none-any.whl

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,5 @@
1
+ """Datagoat: let Datagoat answer a decision step in your agent, with your AI as the fallback."""
2
+ from .client import Datagoat, Decision, DecisionOutput
3
+
4
+ __all__ = ["Datagoat", "Decision", "DecisionOutput"]
5
+ __version__ = "0.2.0"
datagoat_decide/cli.py ADDED
@@ -0,0 +1,279 @@
1
+ """datagoat-decide: connect an agent to Datagoat.
2
+
3
+ scan [path] | scan --traces FILE find decision steps in source (never imported) or in traces
4
+ install add the MCP server and skill to your coding agents
5
+ history FILE.csv past cases with outcomes (or with --events, outcomes from a log)
6
+ backfill TRACES past decisions of your AI, from traces, so a point starts with a record
7
+ outcomes EVENTS.csv outcomes for recorded decisions, from a raw event log
8
+ map module:attr read a compiled LangGraph"""
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import importlib
13
+ import json
14
+ import os
15
+ import sys
16
+
17
+ from .client import Datagoat
18
+ from .install import CLIENTS, install
19
+ from .scan import scan
20
+
21
+
22
+ def _print_scan(body, proposals=None) -> None:
23
+ steps = body["steps"]
24
+ if not steps:
25
+ print("No decision steps found: no model call here returns a yes/no, a fixed set of values or a bounded score.")
26
+ return
27
+ by_step = {p["step_id"]: p for p in (proposals or [])}
28
+ print(f"Found {len(steps)} decision step(s) ({body['framework']}):")
29
+ for s in steps:
30
+ p = by_step.get(s["id"])
31
+ loc = f"{s['location']['file']}:{s['location']['line']}" if s.get("location") else (s.get("note") or "")
32
+ status = (p or {}).get("status") or ("wired" if s.get("wired_point") else "new")
33
+ point = (p or {}).get("point") or s.get("wired_point") or s["proposed_point"]
34
+ what = ""
35
+ if p:
36
+ what = f"choice of {', '.join(p['options'])}" if p["question"] == "choice" else (
37
+ f"yes/no, tiers {'/'.join(p['tier_labels'])}" if p.get("tier_labels") else "yes/no")
38
+ else:
39
+ what = ", ".join(f"{f['name']} ({f['kind']})" for f in s["fields"]) or "schema in trace"
40
+ print(f" [{status}] {point} {loc} {s['framework']}/{s['scope']} {what}")
41
+
42
+
43
+ def _event_args(p, prefix: str, required: bool) -> None:
44
+ if prefix:
45
+ p.add_argument("--events", metavar="FILE", help="event log CSV (case id, event, time) to take outcomes from")
46
+ p.add_argument(f"--{prefix}case-id" if prefix else "--case-id", dest="ev_case_id", required=required and not prefix,
47
+ help="event log column with the case id" + (" (default: --case-id)" if prefix else ""))
48
+ p.add_argument(f"--{prefix}name" if prefix else "--event", dest="ev_event", required=required, help="event log column with the event name")
49
+ p.add_argument(f"--{prefix}time" if prefix else "--time", dest="ev_time", required=required, help="event log column with when it happened")
50
+ p.add_argument("--yes", dest="ev_yes", required=required, help="comma-separated events that mean the outcome happened")
51
+ p.add_argument("--no", dest="ev_no", help="comma-separated events that mean it did not (optional)")
52
+
53
+
54
+ def _split(v):
55
+ return [x.strip() for x in v.split(",") if x.strip()] if v else []
56
+
57
+
58
+ def main(argv=None) -> int:
59
+ ap = argparse.ArgumentParser(prog="datagoat-decide")
60
+ sub = ap.add_subparsers(dest="cmd", required=True)
61
+ sc = sub.add_parser("scan", help="find decision steps in source code without importing it")
62
+ sc.add_argument("path", nargs="?", default=".")
63
+ sc.add_argument("--name", help="agent name shown in the dashboard (default: the folder name)")
64
+ sc.add_argument("--json", action="store_true", help="print the agent map as JSON")
65
+ sc.add_argument("--send", action="store_true", help="send the map to Datagoat (DATAGOAT_API_KEY) and print its proposals")
66
+ sc.add_argument("--traces", metavar="FILE", help="read an exported trace file (OTLP JSON, LangSmith runs, Langfuse observations) instead of source")
67
+ sc.add_argument("--min-runs", type=int, default=5, help="traces: runs a step needs before its outputs are read (default 5)")
68
+ ins = sub.add_parser("install", help="connect your coding agents (Claude Code, Cursor, VS Code, Codex, Gemini, Windsurf) to Datagoat")
69
+ ins.add_argument("--url", help="your Datagoat URL (default: DATAGOAT_BASE_URL)")
70
+ ins.add_argument("--client", help="comma-separated: " + ", ".join(CLIENTS) + ", or all (default: detected)")
71
+ ins.add_argument("--auth", choices=["oauth", "key"], default="oauth", help="oauth: sign in from the client; key: read DATAGOAT_API_KEY")
72
+ ins.add_argument("--dir", default=".")
73
+ ins.add_argument("--dry-run", action="store_true")
74
+ h = sub.add_parser("history", help="import past cases with outcomes from a CSV export for a declared point")
75
+ h.add_argument("file")
76
+ h.add_argument("--point", required=True)
77
+ h.add_argument("--case-id", required=True, help="column that identifies a case (the point's entity)")
78
+ h.add_argument("--time", required=True, help="column with when each case happened")
79
+ h.add_argument("--outcome", help="the outcome column (or give --events)")
80
+ h.add_argument("--positive", default="1", help='value of the outcome column that means it happened (default "1"; case-insensitive)')
81
+ h.add_argument("--fields", help="comma-separated input columns (default: every other column); use the point's input fields")
82
+ h.add_argument("--dry-run", action="store_true", help="read and count, send nothing")
83
+ _event_args(h, prefix="event-", required=False)
84
+ h.add_argument("--horizon-days", type=int, help="with --events: the point's horizon (days from a case to its outcome)")
85
+
86
+ o = sub.add_parser("outcomes", help="send outcomes for recorded decisions from a raw event log (case id, event, time)")
87
+ o.add_argument("file")
88
+ o.add_argument("--point", required=True)
89
+ _event_args(o, prefix="", required=True)
90
+ o.add_argument("--dry-run", action="store_true")
91
+
92
+ b = sub.add_parser("backfill", help="send past decisions of your AI, from an exported trace file, as the point's record")
93
+ b.add_argument("file")
94
+ b.add_argument("--point", required=True)
95
+ b.add_argument("--step", help="span / run name of the decision step (default: the only decision step found)")
96
+ b.add_argument("--case-id", required=True, help="input field (dotted path ok) that identifies a case")
97
+ b.add_argument("--fields", required=True, help="comma-separated input fields to send: the point's input fields. Nothing else leaves this machine")
98
+ b.add_argument("--acted-when", metavar="FIELD=V1,V2", help="yes/no points: the AI acted when this output field has one of these values (default: read a bool field)")
99
+ b.add_argument("--option-from", metavar="FIELD", help="choice points: the output field with the option used")
100
+ b.add_argument("--dry-run", action="store_true")
101
+ m = sub.add_parser("map", help="map a compiled LangGraph: module.path:attribute (a graph, or a function returning one)")
102
+ m.add_argument("target")
103
+ m.add_argument("--name", help="agent name shown in the dashboard")
104
+ m.add_argument("--dry-run", action="store_true", help="print the map instead of sending it")
105
+ a = ap.parse_args(argv)
106
+
107
+ if a.cmd == "scan":
108
+ if a.traces:
109
+ from .traces import detect, load
110
+ runs = load(a.traces)
111
+ body = detect(runs, agent=a.name or os.path.splitext(os.path.basename(a.traces))[0], min_runs=a.min_runs)
112
+ if not a.json:
113
+ print(f"Read {len(runs)} spans/runs from {a.traces}.")
114
+ else:
115
+ body = scan(a.path, name=a.name)
116
+ if a.send:
117
+ if not body["steps"]:
118
+ _print_scan(body)
119
+ return 1
120
+ res = Datagoat().post_agent_map(body)
121
+ if a.json:
122
+ print(json.dumps(res, indent=2))
123
+ else:
124
+ _print_scan(body, res.get("proposals"))
125
+ print(f"agent_id {res.get('agent_id')}")
126
+ return 0
127
+ if a.json:
128
+ print(json.dumps(body, indent=2))
129
+ else:
130
+ _print_scan(body)
131
+ return 0
132
+
133
+ if a.cmd == "history":
134
+ from .history_csv import prepare
135
+ if not a.outcome and not a.events:
136
+ print("Give --outcome (a column in the file) or --events (an event log to take outcomes from).")
137
+ return 2
138
+ p = prepare(a.file, a.case_id, a.time, None if a.events else a.outcome, a.positive, _split(a.fields) or None)
139
+ if p["missing_columns"]:
140
+ print(f"Missing from the file: {', '.join(p['missing_columns'])}.")
141
+ return 2
142
+ rows, sk = p["rows"], p["skipped"]
143
+ if a.events:
144
+ from .events import _dt, label_case, read_events
145
+ if not (a.ev_event and a.ev_time and a.ev_yes and a.horizon_days):
146
+ print("--events needs --event-name, --event-time, --yes and --horizon-days.")
147
+ return 2
148
+ ev = read_events(a.events, a.ev_case_id or a.case_id, a.ev_event, a.ev_time, _split(a.ev_yes), _split(a.ev_no))
149
+ if ev["missing_columns"]:
150
+ print(f"Missing from the event log: {', '.join(ev['missing_columns'])}.")
151
+ return 2
152
+ c = ev["counts"]
153
+ print(f"Event log: {c['rows']} events, {c['yes']} yes, {c['no']} no, {c['other']} other; last at {ev['last'].isoformat() if ev['last'] else 'none'}.")
154
+ labelled, still_open = [], 0
155
+ for r in rows:
156
+ y = label_case(_dt(r["at"]), ev["by_case"].get(r["case_id"], []), a.horizon_days, ev["last"])
157
+ if y is None:
158
+ still_open += 1
159
+ else:
160
+ labelled.append({**r, "outcome": y})
161
+ rows = labelled
162
+ sk = {**sk, "blank_outcome": still_open}
163
+ pos = sum(r["outcome"] for r in rows)
164
+ print(f"{len(rows)} cases ready, {pos} with the outcome; fields {', '.join(p['fields'])}.")
165
+ if any(sk.values()):
166
+ print(f"Left out: {sk['blank_outcome']} with no outcome yet, {sk['bad_time']} with an unreadable date, {sk['no_id']} with no {a.case_id}.")
167
+ if rows and pos in (0, len(rows)) and not a.events:
168
+ print(f'Warning: every case has the same outcome. Check --positive (values are compared to "{a.positive}").')
169
+ if a.dry_run or not rows:
170
+ return 0 if rows else 1
171
+ res = Datagoat().history(a.point, rows)
172
+ print(f"Imported {res['imported']} cases into {a.point}. Sending the file again is safe: a case sent twice replaces itself.")
173
+ return 0
174
+
175
+ if a.cmd == "outcomes":
176
+ from .events import outcomes_from_events, read_events
177
+ ev = read_events(a.file, a.ev_case_id, a.ev_event, a.ev_time, _split(a.ev_yes), _split(a.ev_no))
178
+ if ev["missing_columns"]:
179
+ print(f"Missing from the file: {', '.join(ev['missing_columns'])}.")
180
+ return 2
181
+ c = ev["counts"]
182
+ rows = outcomes_from_events(ev)
183
+ print(f"{c['rows']} events: {c['yes']} mean it happened, {c['no']} mean it did not, {c['other']} ignored; {len(ev['by_case'])} cases.")
184
+ if c["bad_time"] or c["no_id"]:
185
+ print(f"Left out: {c['bad_time']} with an unreadable time, {c['no_id']} with no case id.")
186
+ if a.dry_run or not rows:
187
+ return 0 if rows else 1
188
+ from .client import batches
189
+ dg, sent, matched = Datagoat(), 0, 0
190
+ for chunk in batches(rows):
191
+ r = dg.outcomes(a.point, chunk)
192
+ sent += r["accepted"]
193
+ matched += r["matched_decisions"]
194
+ print(f"Sent {sent} outcomes to {a.point}; {matched} cases match a recorded decision. Datagoat labels each decision by its horizon.")
195
+ return 0
196
+
197
+ if a.cmd == "backfill":
198
+ from .traces import backfill, detect, load
199
+ runs = load(a.file)
200
+ step = a.step
201
+ if not step:
202
+ found = [s["name"] for s in detect(runs)["steps"]]
203
+ if len(found) != 1:
204
+ print(f"Pass --step: {'decision steps in this file: ' + ', '.join(found) if found else 'no decision step found in this file'}.")
205
+ return 2
206
+ step = found[0]
207
+ acted = None
208
+ if a.acted_when:
209
+ f, _, vals = a.acted_when.partition("=")
210
+ acted = (f.strip(), _split(vals))
211
+ r = backfill(runs, step, a.case_id, _split(a.fields), flag_from=acted, option_from=a.option_from)
212
+ ds = r["decisions"]
213
+ print(f"{r['runs']} runs of {step}; {len(ds)} decisions ready" + (f"; left out: {', '.join(f'{v} {k}' for k, v in r['skipped'].items())}" if r["skipped"] else "") + ".")
214
+ if ds:
215
+ print(f"Fields: {', '.join(ds[0]['inputs'])}. First: {ds[0]['decided_at']}, last: {ds[-1]['decided_at']}.")
216
+ if not a.option_from and not any("flag" in d for d in ds):
217
+ print("Note: no yes/no read from the outputs. Pass --acted-when FIELD=VALUES so outcomes the AI prevented are not counted as real 0s.")
218
+ from datetime import datetime, timedelta, timezone
219
+ old = sum(1 for d in ds if d["decided_at"] < (datetime.now(timezone.utc) - timedelta(days=90)).isoformat().replace("+00:00", "Z"))
220
+ if old:
221
+ print(f"Note: {old} of them are more than 90 days old; refits read the last 90 days plus the horizon, so the oldest may not be used.")
222
+ if a.dry_run or not ds:
223
+ return 0 if ds else 1
224
+ res = Datagoat().backfill(a.point, ds)
225
+ print(f"Backfilled {res['inserted']} past decisions into {a.point} ({res['already_had']} were already there"
226
+ + (f", {res['logged_live']} skipped because the wrapped step already logged them" if res.get("logged_live") else "")
227
+ + (f", {res['unknown_option']} with an option that isn't declared" if res.get("unknown_option") else "") + ").")
228
+ if res.get("unflagged"):
229
+ print(f"Warning: {res['unflagged']} have no yes/no from your AI, so the ones it turned away will count as 'no'. Re-run with --acted-when.")
230
+ print("Now send their outcomes: datagoat-decide outcomes <events.csv> …")
231
+ return 0
232
+
233
+ if a.cmd == "install":
234
+ url = a.url or os.environ.get("DATAGOAT_BASE_URL")
235
+ if not url:
236
+ print("Pass --url with your Datagoat URL (shown on the dashboard's Connect page), or set DATAGOAT_BASE_URL.")
237
+ return 2
238
+ clients = CLIENTS if a.client == "all" else ([c.strip() for c in a.client.split(",") if c.strip()] if a.client else None)
239
+ bad = [c for c in clients or [] if c not in CLIENTS]
240
+ if bad:
241
+ print(f"Unknown client {', '.join(bad)}. One of: {', '.join(CLIENTS)}, all.")
242
+ return 2
243
+ d = os.path.abspath(a.dir)
244
+ changes = install(d, url, clients=clients, auth=a.auth, dry_run=a.dry_run)
245
+ for c in changes:
246
+ rel = os.path.relpath(c["file"], d)
247
+ print(f"{'would ' if a.dry_run else ''}{c['action']:<9} {c['file'] if rel.startswith('..') else rel}{' ' + c['note'] if c.get('note') else ''}")
248
+ print("Set DATAGOAT_API_KEY where the client runs. Then restart it and ask it to connect this agent to Datagoat." if a.auth == "key"
249
+ else "Restart the client; it opens a sign-in the first time it uses Datagoat. Then ask it to connect this agent to Datagoat.")
250
+ return 1 if any(c["action"] == "manual" for c in changes) else 0
251
+
252
+ from .langgraph import map_graph # needs langgraph importable; scan does not
253
+
254
+ mod_name, _, attr = a.target.partition(":")
255
+ sys.path.insert(0, ".")
256
+ obj = getattr(importlib.import_module(mod_name), attr or "graph")
257
+ graph = obj() if callable(obj) and not hasattr(obj, "get_graph") else obj
258
+ steps = map_graph(graph, module_prefix=mod_name.split(".")[-1])
259
+ body = {"name": a.name or mod_name, "framework": "langgraph", "steps": steps}
260
+ if a.dry_run:
261
+ print(json.dumps(body, indent=2))
262
+ return 0
263
+ res = Datagoat().post_agent_map(body)
264
+ found = [s for s in steps if s["kind"] == "decision"]
265
+ print(f"Mapped {len(steps)} steps, {len(found)} decision point(s): {', '.join(s['proposed_point'] for s in found) or 'none'}")
266
+ print(f"agent_id {res.get('agent_id')}")
267
+ return 0
268
+
269
+
270
+ def run() -> int:
271
+ try:
272
+ return main()
273
+ except (RuntimeError, ValueError, FileNotFoundError) as e:
274
+ print(f"datagoat-decide: {e}", file=sys.stderr)
275
+ return 1
276
+
277
+
278
+ if __name__ == "__main__":
279
+ raise SystemExit(run())