datagoat-decide 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- datagoat_decide-0.2.0/.gitignore +15 -0
- datagoat_decide-0.2.0/PKG-INFO +57 -0
- datagoat_decide-0.2.0/README.md +42 -0
- datagoat_decide-0.2.0/pyproject.toml +33 -0
- datagoat_decide-0.2.0/src/datagoat_decide/__init__.py +5 -0
- datagoat_decide-0.2.0/src/datagoat_decide/cli.py +279 -0
- datagoat_decide-0.2.0/src/datagoat_decide/client.py +495 -0
- datagoat_decide-0.2.0/src/datagoat_decide/decorator.py +58 -0
- datagoat_decide-0.2.0/src/datagoat_decide/events.py +78 -0
- datagoat_decide-0.2.0/src/datagoat_decide/fallback.py +67 -0
- datagoat_decide-0.2.0/src/datagoat_decide/history_csv.py +72 -0
- datagoat_decide-0.2.0/src/datagoat_decide/install.py +146 -0
- datagoat_decide-0.2.0/src/datagoat_decide/langgraph.py +180 -0
- datagoat_decide-0.2.0/src/datagoat_decide/scan.py +398 -0
- datagoat_decide-0.2.0/src/datagoat_decide/skill/SKILL.md +120 -0
- datagoat_decide-0.2.0/src/datagoat_decide/traces.py +360 -0
- datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/adk_agent.py +9 -0
- datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/broken.py +1 -0
- datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/claude_sdk.py +9 -0
- datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/crew.py +5 -0
- datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/dspy_mod.py +9 -0
- datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/fraud_openai_agents.py +4 -0
- datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/leads_pydantic_ai.py +4 -0
- datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/raw_openai.py +13 -0
- datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/retention.py +22 -0
- datagoat_decide-0.2.0/tests/fixtures/scan_agent/flows/support_agent.py +10 -0
- datagoat_decide-0.2.0/tests/fixtures/scan_agent/pyproject.toml +0 -0
- datagoat_decide-0.2.0/tests/fixtures/scan_agent/schemas/models.py +30 -0
- datagoat_decide-0.2.0/tests/test_install.py +86 -0
- datagoat_decide-0.2.0/tests/test_scan.py +97 -0
- datagoat_decide-0.2.0/tests/test_sdk.py +433 -0
- datagoat_decide-0.2.0/tests/test_traces.py +215 -0
- datagoat_decide-0.2.0/uv.lock +1509 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: datagoat-decide
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Let Datagoat answer a decision step in your agent, with your AI as the fallback.
|
|
5
|
+
Project-URL: Homepage, https://datagoat.io
|
|
6
|
+
Project-URL: Repository, https://github.com/dmilstein-match/datagoat-gde
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: agent,datagoat,decision,fallback,langgraph
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Provides-Extra: langgraph
|
|
12
|
+
Requires-Dist: langgraph>=0.4; extra == 'langgraph'
|
|
13
|
+
Requires-Dist: pydantic>=2; extra == 'langgraph'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# datagoat-decide (Python)
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install datagoat-decide # or: uv add datagoat-decide
|
|
20
|
+
export DATAGOAT_API_KEY=dg_live_...
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Wrap a LangGraph node
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from datagoat_decide import Datagoat
|
|
27
|
+
dg = Datagoat() # reads DATAGOAT_API_KEY and DATAGOAT_BASE_URL (your data1 deployment URL)
|
|
28
|
+
|
|
29
|
+
graph.add_node("qualify", dg.wrap("sdr.qualify", qualify_lead,
|
|
30
|
+
input_key="lead", output_key="qualification", case_id="lead_id"))
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
* **Valid answer** → Datagoat's answer goes into `state["qualification"]`, your node does not run.
|
|
34
|
+
* **No valid answer** → your fallback (set in the dashboard): your node runs as before, or the case
|
|
35
|
+
goes to a person, or no decision (`None`).
|
|
36
|
+
* **Datagoat unreachable or slow** → your node runs. The SDK never blocks your agent past its
|
|
37
|
+
budget (`timeout`, default 2.5 s). Every call carries an idempotency key, so a call that timed
|
|
38
|
+
out after Datagoat logged it is replayed off your agent's path and still graded.
|
|
39
|
+
|
|
40
|
+
## Any function
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
@dg.point("cs.risk_tier", case_id=lambda acct: acct["account_id"])
|
|
44
|
+
def risk_tier(acct: dict) -> dict: ...
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Outcomes
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
dg.outcomes("sdr.qualify", [{"case_id": "L-20419", "value": 1}])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Map your agent
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
datagoat-decide map my_agent.graph:graph --name "SDR agent"
|
|
57
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# datagoat-decide (Python)
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
pip install datagoat-decide # or: uv add datagoat-decide
|
|
5
|
+
export DATAGOAT_API_KEY=dg_live_...
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
## Wrap a LangGraph node
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from datagoat_decide import Datagoat
|
|
12
|
+
dg = Datagoat() # reads DATAGOAT_API_KEY and DATAGOAT_BASE_URL (your data1 deployment URL)
|
|
13
|
+
|
|
14
|
+
graph.add_node("qualify", dg.wrap("sdr.qualify", qualify_lead,
|
|
15
|
+
input_key="lead", output_key="qualification", case_id="lead_id"))
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
* **Valid answer** → Datagoat's answer goes into `state["qualification"]`, your node does not run.
|
|
19
|
+
* **No valid answer** → your fallback (set in the dashboard): your node runs as before, or the case
|
|
20
|
+
goes to a person, or no decision (`None`).
|
|
21
|
+
* **Datagoat unreachable or slow** → your node runs. The SDK never blocks your agent past its
|
|
22
|
+
budget (`timeout`, default 2.5 s). Every call carries an idempotency key, so a call that timed
|
|
23
|
+
out after Datagoat logged it is replayed off your agent's path and still graded.
|
|
24
|
+
|
|
25
|
+
## Any function
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
@dg.point("cs.risk_tier", case_id=lambda acct: acct["account_id"])
|
|
29
|
+
def risk_tier(acct: dict) -> dict: ...
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Outcomes
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
dg.outcomes("sdr.qualify", [{"case_id": "L-20419", "value": 1}])
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Map your agent
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
datagoat-decide map my_agent.graph:graph --name "SDR agent"
|
|
42
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "datagoat-decide"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Let Datagoat answer a decision step in your agent, with your AI as the fallback."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
dependencies = ["httpx>=0.27"]
|
|
9
|
+
keywords = ["datagoat", "agent", "decision", "langgraph", "fallback"]
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Homepage = "https://datagoat.io"
|
|
13
|
+
Repository = "https://github.com/dmilstein-match/datagoat-gde"
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
langgraph = ["langgraph>=0.4", "pydantic>=2"]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
datagoat-decide = "datagoat_decide.cli:run"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = ["pytest>=8", "langgraph>=0.4", "pydantic>=2"]
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["hatchling"]
|
|
26
|
+
build-backend = "hatchling.build"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/datagoat_decide"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
pythonpath = ["src"]
|
|
@@ -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())
|