augplot 0.1.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.
- augplot/__init__.py +25 -0
- augplot/core.py +307 -0
- augplot/datasets.py +51 -0
- augplot/errors.py +45 -0
- augplot/execution.py +1285 -0
- augplot/exporting.py +105 -0
- augplot/history.py +193 -0
- augplot/profiling.py +273 -0
- augplot/prompts.py +208 -0
- augplot/provider.py +57 -0
- augplot-0.1.0.dist-info/METADATA +157 -0
- augplot-0.1.0.dist-info/RECORD +15 -0
- augplot-0.1.0.dist-info/WHEEL +4 -0
- augplot-0.1.0.dist-info/licenses/LICENSE +201 -0
- augplot-0.1.0.dist-info/licenses/NOTICE +3 -0
augplot/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Notebook visualizations with inspectable, reusable Python source."""
|
|
2
|
+
|
|
3
|
+
from .core import plot
|
|
4
|
+
from .datasets import SNS_DATASETS, load_sns_dataset
|
|
5
|
+
from .errors import (
|
|
6
|
+
AugplotError,
|
|
7
|
+
ConfigurationError,
|
|
8
|
+
DataError,
|
|
9
|
+
GenerationError,
|
|
10
|
+
ProviderError,
|
|
11
|
+
ScopeError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"plot",
|
|
16
|
+
"load_sns_dataset",
|
|
17
|
+
"SNS_DATASETS",
|
|
18
|
+
"AugplotError",
|
|
19
|
+
"ConfigurationError",
|
|
20
|
+
"DataError",
|
|
21
|
+
"GenerationError",
|
|
22
|
+
"ProviderError",
|
|
23
|
+
"ScopeError",
|
|
24
|
+
]
|
|
25
|
+
__version__ = "0.1.0"
|
augplot/core.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Public notebook workflow, independent of the inference transport."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import provider
|
|
8
|
+
from .errors import ConfigurationError, GenerationError
|
|
9
|
+
from .execution import API_MANIFEST_VERSION, execute, parse_response
|
|
10
|
+
from .exporting import write_python_function
|
|
11
|
+
from .history import HISTORY_VERSION, History, fingerprint, request_key
|
|
12
|
+
from .profiling import copy_data, profile_data, validate_data
|
|
13
|
+
from .prompts import PROMPT_VERSION, RESPONSE_FORMAT, system_prompt_for
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Visualization:
|
|
17
|
+
"""Generate, refine, and reuse a plot with inspectable Python source.
|
|
18
|
+
|
|
19
|
+
Provider credentials are read by LiteLLM when a request is made. Generated Python
|
|
20
|
+
executes locally after conservative checks; it is not sandboxed. Data samples are
|
|
21
|
+
sent to the configured model. See the README for the supported data and trust model.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
model: str | None = None,
|
|
28
|
+
backend: str = "auto",
|
|
29
|
+
display_format: str = "retina",
|
|
30
|
+
api_base: str | None = None,
|
|
31
|
+
sample_rows: int = 5,
|
|
32
|
+
max_profile_chars: int = 20_000,
|
|
33
|
+
timeout: float = 60,
|
|
34
|
+
max_repairs: int = 1,
|
|
35
|
+
cache_dir: str | Path | None = ".augplot/plots",
|
|
36
|
+
):
|
|
37
|
+
if backend not in {"auto", "matplotlib", "seaborn"}:
|
|
38
|
+
raise ConfigurationError("backend must be auto, matplotlib, or seaborn.")
|
|
39
|
+
if display_format not in {"retina", "png", "svg"}:
|
|
40
|
+
raise ConfigurationError("display_format must be retina, png, or svg.")
|
|
41
|
+
if not isinstance(sample_rows, int) or not 0 <= sample_rows <= 100:
|
|
42
|
+
raise ConfigurationError("sample_rows must be an integer between 0 and 100.")
|
|
43
|
+
if not isinstance(max_profile_chars, int) or not 500 <= max_profile_chars <= 100_000:
|
|
44
|
+
raise ConfigurationError("max_profile_chars must be between 500 and 100,000.")
|
|
45
|
+
if not isinstance(max_repairs, int) or max_repairs not in (0, 1):
|
|
46
|
+
raise ConfigurationError("max_repairs must be 0 or 1.")
|
|
47
|
+
if not isinstance(timeout, (int, float)) or not 0 < timeout < float("inf"):
|
|
48
|
+
raise ConfigurationError("timeout must be a finite positive number of seconds.")
|
|
49
|
+
self.model = model
|
|
50
|
+
self.backend = backend
|
|
51
|
+
self.display_format = display_format
|
|
52
|
+
self.api_base = api_base
|
|
53
|
+
self.sample_rows = sample_rows
|
|
54
|
+
self.max_profile_chars = max_profile_chars
|
|
55
|
+
self.timeout = timeout
|
|
56
|
+
self.max_repairs = max_repairs
|
|
57
|
+
self._history = History(cache_dir) if cache_dir is not None else None
|
|
58
|
+
self.history_path: Path | None = None
|
|
59
|
+
self.cache_hit = False
|
|
60
|
+
self._record = None
|
|
61
|
+
self._root = None
|
|
62
|
+
self._data_fingerprint = None
|
|
63
|
+
self.code: str | None = None
|
|
64
|
+
self.figure = None
|
|
65
|
+
self.explanation: str | None = None
|
|
66
|
+
self.profile: dict | None = None
|
|
67
|
+
self._data = None
|
|
68
|
+
self._prompt = "auto"
|
|
69
|
+
|
|
70
|
+
def _configuration(self):
|
|
71
|
+
model = self.model if self.model is not None else os.getenv("AUGPLOT_MODEL")
|
|
72
|
+
if not isinstance(model, str) or not model.strip():
|
|
73
|
+
raise ConfigurationError("Set AUGPLOT_MODEL or pass model='provider/model-name'.")
|
|
74
|
+
api_base = self.api_base if self.api_base is not None else os.getenv("AUGPLOT_API_BASE")
|
|
75
|
+
return model, api_base
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _validate_prompt(prompt):
|
|
79
|
+
if not isinstance(prompt, str) or not prompt.strip():
|
|
80
|
+
raise ConfigurationError("prompt must be 'auto' or a nonempty instruction string.")
|
|
81
|
+
|
|
82
|
+
def _generate(self, data, profile, prompt, *, previous_code=None, original_prompt=None):
|
|
83
|
+
model, api_base = self._configuration()
|
|
84
|
+
context = {
|
|
85
|
+
"prompt_version": PROMPT_VERSION,
|
|
86
|
+
"operation": "refine" if previous_code is not None else "generate",
|
|
87
|
+
"backend": self.backend,
|
|
88
|
+
"request": prompt,
|
|
89
|
+
"data_profile": profile,
|
|
90
|
+
}
|
|
91
|
+
if previous_code is not None:
|
|
92
|
+
context.update(
|
|
93
|
+
previous_code=previous_code,
|
|
94
|
+
original_request=original_prompt,
|
|
95
|
+
)
|
|
96
|
+
messages = [
|
|
97
|
+
{"role": "system", "content": system_prompt_for(request=prompt, profile=profile)},
|
|
98
|
+
{"role": "user", "content": json.dumps(context)},
|
|
99
|
+
]
|
|
100
|
+
last_error = None
|
|
101
|
+
code = None
|
|
102
|
+
for attempt in range(self.max_repairs + 1):
|
|
103
|
+
response = provider.complete(
|
|
104
|
+
model=model,
|
|
105
|
+
messages=messages,
|
|
106
|
+
api_base=api_base,
|
|
107
|
+
timeout=self.timeout,
|
|
108
|
+
response_format=RESPONSE_FORMAT,
|
|
109
|
+
)
|
|
110
|
+
try:
|
|
111
|
+
code, explanation = parse_response(response)
|
|
112
|
+
figure = execute(code, data, backend=self.backend)
|
|
113
|
+
return code, explanation, figure
|
|
114
|
+
except GenerationError as exc:
|
|
115
|
+
last_error = exc
|
|
116
|
+
if attempt < self.max_repairs:
|
|
117
|
+
messages.extend(
|
|
118
|
+
[
|
|
119
|
+
{"role": "assistant", "content": response[:60_000]},
|
|
120
|
+
{
|
|
121
|
+
"role": "user",
|
|
122
|
+
"content": json.dumps(
|
|
123
|
+
{
|
|
124
|
+
"repair": "Return corrected JSON per the contract.",
|
|
125
|
+
"diagnostic": str(exc)[:500],
|
|
126
|
+
}
|
|
127
|
+
),
|
|
128
|
+
},
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
raise GenerationError(
|
|
132
|
+
f"Could not generate a working visualization after {self.max_repairs + 1} "
|
|
133
|
+
f"attempt(s). {last_error}",
|
|
134
|
+
code=code,
|
|
135
|
+
violations=last_error.violations if last_error is not None else None,
|
|
136
|
+
) from None
|
|
137
|
+
|
|
138
|
+
def _resolve(self, data, profile, prompt, *, data_hash, regenerate, refining=False):
|
|
139
|
+
"""Resolve this exact step; descendants never replace their parent's lookup."""
|
|
140
|
+
model, api_base = self._configuration()
|
|
141
|
+
settings = {
|
|
142
|
+
"history_version": HISTORY_VERSION,
|
|
143
|
+
"api_manifest_version": API_MANIFEST_VERSION,
|
|
144
|
+
"prompt_version": PROMPT_VERSION,
|
|
145
|
+
"data": data_hash,
|
|
146
|
+
"model": model,
|
|
147
|
+
"api_base": api_base,
|
|
148
|
+
"backend": self.backend,
|
|
149
|
+
"sample_rows": self.sample_rows,
|
|
150
|
+
"max_profile_chars": self.max_profile_chars,
|
|
151
|
+
"prompt": prompt,
|
|
152
|
+
}
|
|
153
|
+
if refining:
|
|
154
|
+
settings.update(
|
|
155
|
+
parent=self._record["revision"] if self._record else None,
|
|
156
|
+
previous_code=self.code,
|
|
157
|
+
original_prompt=self._prompt,
|
|
158
|
+
)
|
|
159
|
+
key = request_key(settings)
|
|
160
|
+
root = self._root if refining else key
|
|
161
|
+
if self._history is not None and not regenerate:
|
|
162
|
+
saved = self._history.load(root, key)
|
|
163
|
+
if saved is not None:
|
|
164
|
+
record, code, path = saved
|
|
165
|
+
# Apply the same validation and defensive execution as freshly generated code.
|
|
166
|
+
code, explanation = parse_response(
|
|
167
|
+
json.dumps(
|
|
168
|
+
{"status": "ok", "code": code, "explanation": record["explanation"]}
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
figure = execute(code, data, backend=self.backend)
|
|
173
|
+
except GenerationError as exc:
|
|
174
|
+
raise GenerationError(
|
|
175
|
+
"Saved visualization failed; no LLM request was made. Restore the "
|
|
176
|
+
"compatible environment or use regenerate=True explicitly. " + str(exc),
|
|
177
|
+
code=code,
|
|
178
|
+
violations=exc.violations,
|
|
179
|
+
) from None
|
|
180
|
+
return code, explanation, figure, record, path, root, True
|
|
181
|
+
code, explanation, figure = self._generate(
|
|
182
|
+
data,
|
|
183
|
+
profile,
|
|
184
|
+
prompt,
|
|
185
|
+
previous_code=self.code if refining else None,
|
|
186
|
+
original_prompt=self._prompt if refining else None,
|
|
187
|
+
)
|
|
188
|
+
record, path = None, None
|
|
189
|
+
if self._history is not None:
|
|
190
|
+
record, _, path = self._history.save(
|
|
191
|
+
root,
|
|
192
|
+
key,
|
|
193
|
+
code=code,
|
|
194
|
+
explanation=explanation,
|
|
195
|
+
depth=self._record["depth"] + 1 if refining else 0,
|
|
196
|
+
parent=self._record["revision"] if refining else None,
|
|
197
|
+
data_fingerprint=data_hash,
|
|
198
|
+
)
|
|
199
|
+
return code, explanation, figure, record, path, root, False
|
|
200
|
+
|
|
201
|
+
def _accept(self, resolved):
|
|
202
|
+
(
|
|
203
|
+
self.code, self.explanation, self.figure, self._record,
|
|
204
|
+
self.history_path, self._root, self.cache_hit,
|
|
205
|
+
) = resolved
|
|
206
|
+
|
|
207
|
+
def _display(self, figure):
|
|
208
|
+
"""Render static figures explicitly, without altering notebook formatters or DPI."""
|
|
209
|
+
from IPython import get_ipython
|
|
210
|
+
from IPython.core.pylabtools import print_figure, retina_figure
|
|
211
|
+
from IPython.display import SVG, Image, display
|
|
212
|
+
|
|
213
|
+
# Do not open browser windows or print Figure reprs from scripts.
|
|
214
|
+
shell = get_ipython()
|
|
215
|
+
if shell is not None and getattr(shell, "kernel", None) is not None:
|
|
216
|
+
if self.display_format == "retina":
|
|
217
|
+
rendered = retina_figure(figure)
|
|
218
|
+
if rendered is not None:
|
|
219
|
+
png, dimensions = rendered
|
|
220
|
+
display(Image(data=png, format="png", **dimensions))
|
|
221
|
+
elif self.display_format == "svg":
|
|
222
|
+
svg = print_figure(figure, fmt="svg")
|
|
223
|
+
if svg is not None:
|
|
224
|
+
display(SVG(data=svg))
|
|
225
|
+
else:
|
|
226
|
+
png = print_figure(figure, fmt="png")
|
|
227
|
+
if png is not None:
|
|
228
|
+
display(Image(data=png, format="png"))
|
|
229
|
+
|
|
230
|
+
def fit(self, data, prompt: str = "auto", *, show: bool = True, regenerate: bool = False):
|
|
231
|
+
"""Reuse or generate the original plot. Replace existing state only after success."""
|
|
232
|
+
self._validate_prompt(prompt)
|
|
233
|
+
self._configuration()
|
|
234
|
+
profile = profile_data(data, sample_rows=self.sample_rows, max_chars=self.max_profile_chars)
|
|
235
|
+
snapshot = copy_data(data)
|
|
236
|
+
data_hash = fingerprint(snapshot) if self._history is not None else None
|
|
237
|
+
resolved = self._resolve(
|
|
238
|
+
snapshot, profile, prompt, data_hash=data_hash, regenerate=regenerate
|
|
239
|
+
)
|
|
240
|
+
self._data, self._prompt = snapshot, prompt
|
|
241
|
+
self.profile, self._data_fingerprint = profile, data_hash
|
|
242
|
+
self._accept(resolved)
|
|
243
|
+
if show:
|
|
244
|
+
self._display(self.figure)
|
|
245
|
+
return self
|
|
246
|
+
|
|
247
|
+
def _require_fit(self):
|
|
248
|
+
if self.code is None:
|
|
249
|
+
raise ConfigurationError(
|
|
250
|
+
"Call fit(data) before refining, rendering, or writing Python code."
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def data_fingerprint(self):
|
|
255
|
+
"""Full fitted-data hash, or None before fit / when persistence is disabled."""
|
|
256
|
+
return self._data_fingerprint
|
|
257
|
+
|
|
258
|
+
def refine(self, prompt: str, *, show: bool = True, regenerate: bool = False):
|
|
259
|
+
"""Reuse or generate a revision identified by its parent and instruction."""
|
|
260
|
+
self._require_fit()
|
|
261
|
+
self._validate_prompt(prompt)
|
|
262
|
+
resolved = self._resolve(
|
|
263
|
+
self._data,
|
|
264
|
+
self.profile,
|
|
265
|
+
prompt,
|
|
266
|
+
data_hash=self._data_fingerprint,
|
|
267
|
+
regenerate=regenerate,
|
|
268
|
+
refining=True,
|
|
269
|
+
)
|
|
270
|
+
self._accept(resolved)
|
|
271
|
+
if show:
|
|
272
|
+
self._display(self.figure)
|
|
273
|
+
return self
|
|
274
|
+
|
|
275
|
+
def render(self, data=None, *, title=None, figsize=None, show: bool = True):
|
|
276
|
+
"""Run the saved function locally on matching data; return self, with no LLM call.
|
|
277
|
+
|
|
278
|
+
The fitted data remains the basis for future refinement. Pass new data to fit()
|
|
279
|
+
if the schema or the data used for refinement should change.
|
|
280
|
+
"""
|
|
281
|
+
self._require_fit()
|
|
282
|
+
selected = self._data if data is None else data
|
|
283
|
+
validate_data(selected)
|
|
284
|
+
figure = execute(self.code, selected, backend=self.backend, title=title, figsize=figsize)
|
|
285
|
+
self.figure = figure
|
|
286
|
+
if show:
|
|
287
|
+
self._display(figure)
|
|
288
|
+
return self
|
|
289
|
+
|
|
290
|
+
def to_python(
|
|
291
|
+
self, path: str | Path | None = None, *, function_name: str = "plot_visualization"
|
|
292
|
+
) -> Path:
|
|
293
|
+
"""Write reusable Python to a local module, without an LLM call."""
|
|
294
|
+
self._require_fit()
|
|
295
|
+
target = Path("augplot_utils.py") if path is None else Path(path)
|
|
296
|
+
return write_python_function(self.code, target, function_name, backend=self.backend)
|
|
297
|
+
|
|
298
|
+
def __repr__(self):
|
|
299
|
+
state = "fitted" if self.code is not None else "unfitted"
|
|
300
|
+
return f"Augplot(backend={self.backend!r}, state={state!r})"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def plot(
|
|
304
|
+
data, prompt: str = "auto", *, show: bool = True, regenerate: bool = False, **kwargs
|
|
305
|
+
) -> _Visualization:
|
|
306
|
+
"""Create a visualization that can be refined, rendered, and written as Python."""
|
|
307
|
+
return _Visualization(**kwargs).fit(data, prompt=prompt, show=show, regenerate=regenerate)
|
augplot/datasets.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Convenient access to Seaborn's online example datasets."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import seaborn as sns
|
|
7
|
+
|
|
8
|
+
SNS_DATASETS = (
|
|
9
|
+
"anagrams",
|
|
10
|
+
"anscombe",
|
|
11
|
+
"attention",
|
|
12
|
+
"brain_networks",
|
|
13
|
+
"car_crashes",
|
|
14
|
+
"diamonds",
|
|
15
|
+
"dots",
|
|
16
|
+
"dowjones",
|
|
17
|
+
"exercise",
|
|
18
|
+
"flights",
|
|
19
|
+
"fmri",
|
|
20
|
+
"geyser",
|
|
21
|
+
"glue",
|
|
22
|
+
"healthexp",
|
|
23
|
+
"iris",
|
|
24
|
+
"mpg",
|
|
25
|
+
"penguins",
|
|
26
|
+
"planets",
|
|
27
|
+
"seaice",
|
|
28
|
+
"taxis",
|
|
29
|
+
"tips",
|
|
30
|
+
"titanic",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_sns_dataset(
|
|
35
|
+
name: str,
|
|
36
|
+
*,
|
|
37
|
+
cache: bool = True,
|
|
38
|
+
data_home: str | None = None,
|
|
39
|
+
**kwargs: Any,
|
|
40
|
+
) -> pd.DataFrame:
|
|
41
|
+
"""Load one of Seaborn's example datasets without importing Seaborn yourself.
|
|
42
|
+
|
|
43
|
+
The first load may download the dataset from Seaborn's public data repository.
|
|
44
|
+
Seaborn caches downloads locally by default. Extra keyword arguments are passed
|
|
45
|
+
to :func:`seaborn.load_dataset` and then to ``pandas.read_csv``.
|
|
46
|
+
"""
|
|
47
|
+
if name not in SNS_DATASETS:
|
|
48
|
+
available = ", ".join(SNS_DATASETS)
|
|
49
|
+
raise ValueError(f"Unknown Seaborn dataset {name!r}. Available datasets: {available}.")
|
|
50
|
+
|
|
51
|
+
return sns.load_dataset(name, cache=cache, data_home=data_home, **kwargs)
|
augplot/errors.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Public exceptions. Provider/runtime diagnostics never include raw data or credentials."""
|
|
2
|
+
|
|
3
|
+
from typing import TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ValidationViolation(TypedDict):
|
|
7
|
+
"""A machine-readable generated-code validation failure."""
|
|
8
|
+
|
|
9
|
+
rule: str
|
|
10
|
+
message: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AugplotError(Exception):
|
|
14
|
+
"""Base class for Augplot errors."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigurationError(AugplotError, ValueError):
|
|
18
|
+
"""Missing or invalid configuration."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DataError(AugplotError, ValueError):
|
|
22
|
+
"""Unsupported or unusable input data."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProviderError(AugplotError):
|
|
26
|
+
"""The configured model could not be called."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ScopeError(AugplotError, ValueError):
|
|
30
|
+
"""The request needs upstream modeling or prediction inputs, not plotting code."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GenerationError(AugplotError):
|
|
34
|
+
"""The model did not produce a valid, executable plot."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str,
|
|
39
|
+
*,
|
|
40
|
+
code: str | None = None,
|
|
41
|
+
violations: list[ValidationViolation] | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.code = code
|
|
45
|
+
self.violations = list(violations or [])
|