cleanframe-engine 0.3.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.
- cleanframe/__init__.py +169 -0
- cleanframe/__main__.py +5 -0
- cleanframe/_util.py +438 -0
- cleanframe/_version.py +1 -0
- cleanframe/api.py +559 -0
- cleanframe/cli.py +688 -0
- cleanframe/codegen.py +666 -0
- cleanframe/dataio.py +506 -0
- cleanframe/detectors/__init__.py +43 -0
- cleanframe/detectors/base.py +221 -0
- cleanframe/detectors/categories.py +199 -0
- cleanframe/detectors/contacts.py +105 -0
- cleanframe/detectors/currency.py +112 -0
- cleanframe/detectors/dates.py +204 -0
- cleanframe/detectors/dedup.py +150 -0
- cleanframe/detectors/nulls.py +109 -0
- cleanframe/detectors/outliers.py +73 -0
- cleanframe/detectors/schema_mapping.py +125 -0
- cleanframe/detectors/text.py +105 -0
- cleanframe/detectors/units.py +86 -0
- cleanframe/diff.py +369 -0
- cleanframe/drift.py +283 -0
- cleanframe/errors.py +66 -0
- cleanframe/executor.py +229 -0
- cleanframe/fingerprint.py +83 -0
- cleanframe/issues.py +186 -0
- cleanframe/llm.py +811 -0
- cleanframe/ops.py +1245 -0
- cleanframe/planner.py +353 -0
- cleanframe/profile.py +413 -0
- cleanframe/py.typed +1 -0
- cleanframe/quality.py +81 -0
- cleanframe/readfix.py +160 -0
- cleanframe/recipe.py +398 -0
- cleanframe/report.py +345 -0
- cleanframe/result.py +144 -0
- cleanframe/schema.py +259 -0
- cleanframe/streaming.py +354 -0
- cleanframe/types.py +119 -0
- cleanframe/validate.py +363 -0
- cleanframe/workbook.py +370 -0
- cleanframe_engine-0.3.0.dist-info/METADATA +323 -0
- cleanframe_engine-0.3.0.dist-info/RECORD +46 -0
- cleanframe_engine-0.3.0.dist-info/WHEEL +4 -0
- cleanframe_engine-0.3.0.dist-info/entry_points.txt +2 -0
- cleanframe_engine-0.3.0.dist-info/licenses/LICENSE +201 -0
cleanframe/llm.py
ADDED
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
"""Optional LLM-assisted planning — the plan-writer that never sees your data.
|
|
2
|
+
|
|
3
|
+
The contract, enforced structurally:
|
|
4
|
+
|
|
5
|
+
1. An :class:`LLMPlanner` builds a prompt from **metadata only** — column names,
|
|
6
|
+
dtypes, semantic types, null/cardinality stats, and *pattern sketches* of values
|
|
7
|
+
(``₹99,99,999``-style), never raw cells — unless the caller explicitly opts into
|
|
8
|
+
:data:`LLMExposure.SAMPLE` and passes an approved sample.
|
|
9
|
+
2. The model returns a JSON **recipe**, which is parsed through the *same*
|
|
10
|
+
:class:`~cleanframe.recipe.Recipe` model the rules planner uses. Anything the
|
|
11
|
+
model asks for is therefore just a deterministic plan the executor runs — the
|
|
12
|
+
model has no path to your rows.
|
|
13
|
+
3. A hard :data:`max_tokens_budget` aborts before it gets expensive, and any
|
|
14
|
+
failure (no key, bad JSON, over budget) falls back to the rules planner so the
|
|
15
|
+
pipeline never dies because an LLM was flaky.
|
|
16
|
+
|
|
17
|
+
Providers are pluggable. An ``LLMClient`` is anything with a ``complete`` method;
|
|
18
|
+
built-in Anthropic/OpenAI adapters import their SDKs lazily, so this module (and
|
|
19
|
+
all of CleanFrame) imports fine with neither installed.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import warnings
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any, Protocol
|
|
30
|
+
|
|
31
|
+
import pandas as pd
|
|
32
|
+
|
|
33
|
+
from ._util import DETECTOR_SAMPLE_CAP, sample_non_null
|
|
34
|
+
from .errors import BudgetExceeded, CleanFrameWarning, LLMError
|
|
35
|
+
from .issues import Issues
|
|
36
|
+
from .ops import list_ops
|
|
37
|
+
from .profile import DataFrameProfile
|
|
38
|
+
from .recipe import Recipe
|
|
39
|
+
from .types import LLMExposure, Mode
|
|
40
|
+
|
|
41
|
+
#: Default HTTP timeout (seconds) for LLM provider calls.
|
|
42
|
+
LLM_HTTP_TIMEOUT = 60.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Client interface + provider adapters
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
@dataclass
|
|
49
|
+
class LLMResponse:
|
|
50
|
+
text: str
|
|
51
|
+
input_tokens: int = 0
|
|
52
|
+
output_tokens: int = 0
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def total_tokens(self) -> int:
|
|
56
|
+
return self.input_tokens + self.output_tokens
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LLMClient(Protocol):
|
|
60
|
+
"""Minimal provider interface. ``complete`` returns text plus token accounting."""
|
|
61
|
+
|
|
62
|
+
model: str
|
|
63
|
+
|
|
64
|
+
def complete(self, system: str, user: str, *, max_tokens: int = 2048) -> LLMResponse: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AnthropicClient:
|
|
68
|
+
"""Adapter for the Anthropic Messages API. Requires ``anthropic`` + ``ANTHROPIC_API_KEY``."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, model: str, api_key: str | None = None) -> None:
|
|
71
|
+
self.model = model
|
|
72
|
+
self._api_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
73
|
+
|
|
74
|
+
def complete(self, system: str, user: str, *, max_tokens: int = 2048) -> LLMResponse:
|
|
75
|
+
if not self._api_key:
|
|
76
|
+
raise LLMError("ANTHROPIC_API_KEY is not set.")
|
|
77
|
+
try:
|
|
78
|
+
import anthropic
|
|
79
|
+
except ImportError as exc: # pragma: no cover - optional dep
|
|
80
|
+
raise LLMError("The 'anthropic' package is required. Install cleanframe-engine[llm].") from exc
|
|
81
|
+
client = anthropic.Anthropic(api_key=self._api_key, timeout=LLM_HTTP_TIMEOUT)
|
|
82
|
+
msg = client.messages.create(
|
|
83
|
+
model=self.model,
|
|
84
|
+
max_tokens=max_tokens,
|
|
85
|
+
system=system,
|
|
86
|
+
messages=[{"role": "user", "content": user}],
|
|
87
|
+
)
|
|
88
|
+
text = "".join(block.text for block in msg.content if getattr(block, "type", "") == "text")
|
|
89
|
+
return LLMResponse(text, msg.usage.input_tokens, msg.usage.output_tokens)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class OpenAIClient:
|
|
93
|
+
"""Adapter for OpenAI Chat Completions and any OpenAI-compatible endpoint.
|
|
94
|
+
|
|
95
|
+
Used for OpenAI, OpenRouter, Groq, Together, DeepSeek, Mistral, Gemini's
|
|
96
|
+
OpenAI surface, Ollama, and anything else that speaks the same protocol.
|
|
97
|
+
Pass ``base_url`` / ``api_key`` explicitly, or rely on env vars (see
|
|
98
|
+
:data:`PROVIDERS`).
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
model: str,
|
|
104
|
+
api_key: str | None = None,
|
|
105
|
+
base_url: str | None = None,
|
|
106
|
+
*,
|
|
107
|
+
default_headers: dict[str, str] | None = None,
|
|
108
|
+
key_env: str = "OPENAI_API_KEY",
|
|
109
|
+
) -> None:
|
|
110
|
+
self.model = model
|
|
111
|
+
self._api_key = api_key or os.environ.get(key_env) or os.environ.get("OPENAI_API_KEY")
|
|
112
|
+
self._base_url = base_url or os.environ.get("OPENAI_BASE_URL")
|
|
113
|
+
self._default_headers = default_headers
|
|
114
|
+
self._key_env = key_env
|
|
115
|
+
|
|
116
|
+
def complete(self, system: str, user: str, *, max_tokens: int = 2048) -> LLMResponse:
|
|
117
|
+
if not self._api_key:
|
|
118
|
+
raise LLMError(
|
|
119
|
+
f"{self._key_env} is not set"
|
|
120
|
+
+ (
|
|
121
|
+
" (or OPENAI_API_KEY as a fallback)."
|
|
122
|
+
if self._key_env != "OPENAI_API_KEY"
|
|
123
|
+
else "."
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
import openai
|
|
128
|
+
except ImportError as exc: # pragma: no cover - optional dep
|
|
129
|
+
raise LLMError("The 'openai' package is required. Install cleanframe-engine[llm].") from exc
|
|
130
|
+
kwargs: dict[str, Any] = {"api_key": self._api_key, "timeout": LLM_HTTP_TIMEOUT}
|
|
131
|
+
if self._base_url:
|
|
132
|
+
kwargs["base_url"] = self._base_url
|
|
133
|
+
if self._default_headers:
|
|
134
|
+
kwargs["default_headers"] = self._default_headers
|
|
135
|
+
client = openai.OpenAI(**kwargs)
|
|
136
|
+
resp = client.chat.completions.create(
|
|
137
|
+
model=self.model,
|
|
138
|
+
max_tokens=max_tokens,
|
|
139
|
+
messages=[
|
|
140
|
+
{"role": "system", "content": system},
|
|
141
|
+
{"role": "user", "content": user},
|
|
142
|
+
],
|
|
143
|
+
)
|
|
144
|
+
usage = resp.usage
|
|
145
|
+
return LLMResponse(
|
|
146
|
+
resp.choices[0].message.content or "",
|
|
147
|
+
getattr(usage, "prompt_tokens", 0) or 0,
|
|
148
|
+
getattr(usage, "completion_tokens", 0) or 0,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Provider registry — almost everyone speaks OpenAI Chat Completions
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class ProviderSpec:
|
|
157
|
+
"""How to reach an OpenAI-compatible (or Anthropic-native) provider."""
|
|
158
|
+
|
|
159
|
+
name: str
|
|
160
|
+
kind: str # "openai" | "anthropic"
|
|
161
|
+
base_url: str | None = None
|
|
162
|
+
key_env: str = "OPENAI_API_KEY"
|
|
163
|
+
default_key: str | None = None # e.g. "ollama" for local servers
|
|
164
|
+
default_headers: dict[str, str] | None = None
|
|
165
|
+
aliases: tuple[str, ...] = ()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Built-in providers. Specs use ``provider/model``; for OpenRouter the model may
|
|
169
|
+
# itself contain slashes (``openrouter/anthropic/claude-sonnet-4``).
|
|
170
|
+
_PROVIDER_LIST: list[ProviderSpec] = [
|
|
171
|
+
ProviderSpec("anthropic", kind="anthropic", key_env="ANTHROPIC_API_KEY"),
|
|
172
|
+
ProviderSpec("openai", kind="openai", key_env="OPENAI_API_KEY"),
|
|
173
|
+
ProviderSpec(
|
|
174
|
+
"openrouter",
|
|
175
|
+
kind="openai",
|
|
176
|
+
base_url="https://openrouter.ai/api/v1",
|
|
177
|
+
key_env="OPENROUTER_API_KEY",
|
|
178
|
+
# OpenRouter optionally uses these for rankings; harmless if unset.
|
|
179
|
+
default_headers={
|
|
180
|
+
"HTTP-Referer": "https://github.com/inboxpraveen/Cleanframe",
|
|
181
|
+
"X-Title": "CleanFrame",
|
|
182
|
+
},
|
|
183
|
+
),
|
|
184
|
+
ProviderSpec(
|
|
185
|
+
"groq",
|
|
186
|
+
kind="openai",
|
|
187
|
+
base_url="https://api.groq.com/openai/v1",
|
|
188
|
+
key_env="GROQ_API_KEY",
|
|
189
|
+
),
|
|
190
|
+
ProviderSpec(
|
|
191
|
+
"together",
|
|
192
|
+
kind="openai",
|
|
193
|
+
base_url="https://api.together.xyz/v1",
|
|
194
|
+
key_env="TOGETHER_API_KEY",
|
|
195
|
+
aliases=("togetherai",),
|
|
196
|
+
),
|
|
197
|
+
ProviderSpec(
|
|
198
|
+
"fireworks",
|
|
199
|
+
kind="openai",
|
|
200
|
+
base_url="https://api.fireworks.ai/inference/v1",
|
|
201
|
+
key_env="FIREWORKS_API_KEY",
|
|
202
|
+
aliases=("fireworksai",),
|
|
203
|
+
),
|
|
204
|
+
ProviderSpec(
|
|
205
|
+
"deepseek",
|
|
206
|
+
kind="openai",
|
|
207
|
+
base_url="https://api.deepseek.com",
|
|
208
|
+
key_env="DEEPSEEK_API_KEY",
|
|
209
|
+
),
|
|
210
|
+
ProviderSpec(
|
|
211
|
+
"mistral",
|
|
212
|
+
kind="openai",
|
|
213
|
+
base_url="https://api.mistral.ai/v1",
|
|
214
|
+
key_env="MISTRAL_API_KEY",
|
|
215
|
+
),
|
|
216
|
+
ProviderSpec(
|
|
217
|
+
"google",
|
|
218
|
+
kind="openai",
|
|
219
|
+
# Gemini's OpenAI-compatible endpoint
|
|
220
|
+
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
221
|
+
key_env="GOOGLE_API_KEY",
|
|
222
|
+
aliases=("gemini",),
|
|
223
|
+
),
|
|
224
|
+
ProviderSpec(
|
|
225
|
+
"xai",
|
|
226
|
+
kind="openai",
|
|
227
|
+
base_url="https://api.x.ai/v1",
|
|
228
|
+
key_env="XAI_API_KEY",
|
|
229
|
+
aliases=("grok",),
|
|
230
|
+
),
|
|
231
|
+
ProviderSpec(
|
|
232
|
+
"perplexity",
|
|
233
|
+
kind="openai",
|
|
234
|
+
base_url="https://api.perplexity.ai",
|
|
235
|
+
key_env="PERPLEXITY_API_KEY",
|
|
236
|
+
),
|
|
237
|
+
ProviderSpec(
|
|
238
|
+
"cohere",
|
|
239
|
+
kind="openai",
|
|
240
|
+
base_url="https://api.cohere.ai/compatibility/v1",
|
|
241
|
+
key_env="COHERE_API_KEY",
|
|
242
|
+
),
|
|
243
|
+
ProviderSpec(
|
|
244
|
+
"ollama",
|
|
245
|
+
kind="openai",
|
|
246
|
+
base_url="http://localhost:11434/v1",
|
|
247
|
+
key_env="OPENAI_API_KEY",
|
|
248
|
+
default_key="ollama",
|
|
249
|
+
),
|
|
250
|
+
ProviderSpec(
|
|
251
|
+
"lmstudio",
|
|
252
|
+
kind="openai",
|
|
253
|
+
base_url="http://localhost:1234/v1",
|
|
254
|
+
key_env="OPENAI_API_KEY",
|
|
255
|
+
default_key="lmstudio",
|
|
256
|
+
aliases=("lm-studio",),
|
|
257
|
+
),
|
|
258
|
+
# Generic: honour OPENAI_BASE_URL / OPENAI_API_KEY from the environment.
|
|
259
|
+
ProviderSpec("azure", kind="openai", key_env="OPENAI_API_KEY", aliases=("azure-openai",)),
|
|
260
|
+
ProviderSpec(
|
|
261
|
+
"openai-compatible",
|
|
262
|
+
kind="openai",
|
|
263
|
+
key_env="OPENAI_API_KEY",
|
|
264
|
+
aliases=("compatible", "local"),
|
|
265
|
+
),
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
PROVIDERS: dict[str, ProviderSpec] = {}
|
|
269
|
+
for _spec in _PROVIDER_LIST:
|
|
270
|
+
PROVIDERS[_spec.name] = _spec
|
|
271
|
+
for _alias in _spec.aliases:
|
|
272
|
+
PROVIDERS[_alias] = _spec
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def list_providers() -> list[str]:
|
|
276
|
+
"""Canonical provider names (aliases omitted)."""
|
|
277
|
+
return sorted({s.name for s in _PROVIDER_LIST})
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def get_client(spec: str) -> LLMClient:
|
|
281
|
+
"""Resolve a ``"provider/model"`` string.
|
|
282
|
+
|
|
283
|
+
Examples::
|
|
284
|
+
|
|
285
|
+
anthropic/claude-sonnet-4-6
|
|
286
|
+
openai/gpt-4o
|
|
287
|
+
openrouter/anthropic/claude-sonnet-4 # model may contain slashes
|
|
288
|
+
groq/llama-3.3-70b-versatile
|
|
289
|
+
together/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo
|
|
290
|
+
google/gemini-2.0-flash
|
|
291
|
+
ollama/llama3.2
|
|
292
|
+
openai-compatible/my-model # set OPENAI_BASE_URL
|
|
293
|
+
|
|
294
|
+
Almost every hosted provider speaks OpenAI Chat Completions; only Anthropic
|
|
295
|
+
uses a native adapter. Unknown providers raise :class:`LLMError` listing the
|
|
296
|
+
supported names.
|
|
297
|
+
"""
|
|
298
|
+
if not isinstance(spec, str):
|
|
299
|
+
raise LLMError(
|
|
300
|
+
f"LLM spec must be a 'provider/model' string, got {type(spec).__name__}."
|
|
301
|
+
)
|
|
302
|
+
if "/" not in spec:
|
|
303
|
+
raise LLMError(f"LLM spec must be 'provider/model', got {spec!r}.")
|
|
304
|
+
provider, model = spec.split("/", 1)
|
|
305
|
+
provider = provider.lower()
|
|
306
|
+
info = PROVIDERS.get(provider)
|
|
307
|
+
if info is None:
|
|
308
|
+
known = ", ".join(list_providers())
|
|
309
|
+
raise LLMError(f"Unknown LLM provider {provider!r}. Supported: {known}.")
|
|
310
|
+
|
|
311
|
+
if info.kind == "anthropic":
|
|
312
|
+
return AnthropicClient(model)
|
|
313
|
+
|
|
314
|
+
# OPENAI_BASE_URL may only redirect the openai / openai-compatible / azure
|
|
315
|
+
# family — never OpenRouter/Groq/etc., where a leaked env would exfiltrate keys.
|
|
316
|
+
if info.name in ("openai", "openai-compatible", "azure"):
|
|
317
|
+
base_url = os.environ.get("OPENAI_BASE_URL") or info.base_url
|
|
318
|
+
else:
|
|
319
|
+
base_url = info.base_url
|
|
320
|
+
api_key = os.environ.get(info.key_env) or os.environ.get("OPENAI_API_KEY") or info.default_key
|
|
321
|
+
# Google also accepts GEMINI_API_KEY
|
|
322
|
+
if info.name == "google" and not api_key:
|
|
323
|
+
api_key = os.environ.get("GEMINI_API_KEY")
|
|
324
|
+
|
|
325
|
+
return OpenAIClient(
|
|
326
|
+
model,
|
|
327
|
+
api_key=api_key,
|
|
328
|
+
base_url=base_url,
|
|
329
|
+
default_headers=info.default_headers,
|
|
330
|
+
key_env=info.key_env,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# Metadata extraction (what the model is allowed to see)
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
def _sketch(value: str) -> str:
|
|
338
|
+
"""Structural sketch of a value: digits→9, letters→A, runs collapsed with ``+``."""
|
|
339
|
+
out: list[str] = []
|
|
340
|
+
for ch in value:
|
|
341
|
+
if ch.isdigit():
|
|
342
|
+
cls = "9"
|
|
343
|
+
elif ch.isalpha():
|
|
344
|
+
cls = "A"
|
|
345
|
+
else:
|
|
346
|
+
cls = ch
|
|
347
|
+
if out and out[-1].rstrip("+") == cls:
|
|
348
|
+
if not out[-1].endswith("+"):
|
|
349
|
+
out[-1] = cls + "+"
|
|
350
|
+
else:
|
|
351
|
+
out.append(cls)
|
|
352
|
+
return "".join(out)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def value_sketches(series: pd.Series, k: int = 3) -> list[str]:
|
|
356
|
+
"""Top-``k`` structural patterns for a column (no raw values leak)."""
|
|
357
|
+
counts: dict[str, int] = {}
|
|
358
|
+
for v in series.dropna().head(500).tolist():
|
|
359
|
+
s = _sketch(v if isinstance(v, str) else str(v))
|
|
360
|
+
counts[s] = counts.get(s, 0) + 1
|
|
361
|
+
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
362
|
+
return [s for s, _ in ranked[:k]]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _anonymize_value(value: Any, semantic_type: str) -> str:
|
|
366
|
+
"""Redact PII-ish samples while preserving enough shape for planning."""
|
|
367
|
+
s = str(value)
|
|
368
|
+
if semantic_type == "email":
|
|
369
|
+
return "user@example.com"
|
|
370
|
+
if semantic_type == "phone":
|
|
371
|
+
digits = re.sub(r"\D", "", s)
|
|
372
|
+
return ("+" + "X" * max(len(digits), 10)) if digits else "+XXXXXXXXXX"
|
|
373
|
+
if semantic_type in ("currency", "float", "integer", "unit"):
|
|
374
|
+
return _sketch(s)
|
|
375
|
+
if semantic_type in ("id",):
|
|
376
|
+
return _sketch(s)
|
|
377
|
+
# Categories / text: keep short tokens (city names help planning) but mask long strings.
|
|
378
|
+
if len(s) > 24:
|
|
379
|
+
return _sketch(s)
|
|
380
|
+
return s
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _sample_for_llm(cp: Any, series: pd.Series, *, k: int = 5) -> list[str]:
|
|
384
|
+
"""Deterministically shuffled, anonymized sample values for SAMPLE exposure."""
|
|
385
|
+
# Cap materialisation so SAMPLE mode cannot load a multi-million-row column.
|
|
386
|
+
values = sample_non_null(series, cap=min(DETECTOR_SAMPLE_CAP, 10_000))
|
|
387
|
+
# Stable shuffle keyed by column name — same input → same sample order.
|
|
388
|
+
seed = sum(ord(c) for c in cp.name) % 2_147_483_647 or 1
|
|
389
|
+
rng = __import__("random").Random(seed)
|
|
390
|
+
shuffled = list(values)
|
|
391
|
+
rng.shuffle(shuffled)
|
|
392
|
+
# Prefer distinct values, then anonymize.
|
|
393
|
+
seen: set[str] = set()
|
|
394
|
+
out: list[str] = []
|
|
395
|
+
for v in shuffled:
|
|
396
|
+
anon = _anonymize_value(v, cp.semantic_type)
|
|
397
|
+
if anon in seen:
|
|
398
|
+
continue
|
|
399
|
+
seen.add(anon)
|
|
400
|
+
out.append(anon)
|
|
401
|
+
if len(out) >= k:
|
|
402
|
+
break
|
|
403
|
+
return out
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def build_metadata(
|
|
407
|
+
df: pd.DataFrame,
|
|
408
|
+
profile: DataFrameProfile,
|
|
409
|
+
issues: Issues,
|
|
410
|
+
schema: Any | None,
|
|
411
|
+
exposure: LLMExposure,
|
|
412
|
+
) -> dict:
|
|
413
|
+
"""Assemble the JSON-serialisable metadata payload sent to the model."""
|
|
414
|
+
columns = []
|
|
415
|
+
for cp in profile.columns:
|
|
416
|
+
entry: dict[str, Any] = {
|
|
417
|
+
"name": cp.name,
|
|
418
|
+
"dtype": cp.dtype,
|
|
419
|
+
"semantic_type": cp.semantic_type,
|
|
420
|
+
"null_fraction": round(cp.null_fraction, 3),
|
|
421
|
+
"unique_count": cp.unique_count,
|
|
422
|
+
"patterns": value_sketches(df[cp.name]),
|
|
423
|
+
}
|
|
424
|
+
if exposure == LLMExposure.SAMPLE:
|
|
425
|
+
entry["example_values"] = _sample_for_llm(cp, df[cp.name])
|
|
426
|
+
entry["sample_note"] = (
|
|
427
|
+
"PII patterns (emails/phones) and long strings are redacted; short "
|
|
428
|
+
"category/text values are sent verbatim — approve before sending off-machine"
|
|
429
|
+
)
|
|
430
|
+
columns.append(entry)
|
|
431
|
+
|
|
432
|
+
payload: dict[str, Any] = {
|
|
433
|
+
"row_count": profile.n_rows,
|
|
434
|
+
"columns": columns,
|
|
435
|
+
# Only the structured facts of each issue are forwarded — NOT the free-text
|
|
436
|
+
# message, which can embed a raw cell value (a constant column's value, a
|
|
437
|
+
# disguised-null token, …). Under METADATA exposure no raw value may leave.
|
|
438
|
+
"detected_issues": [
|
|
439
|
+
{
|
|
440
|
+
"column": i.column,
|
|
441
|
+
"kind": i.kind,
|
|
442
|
+
"severity": i.severity.value,
|
|
443
|
+
}
|
|
444
|
+
for i in issues
|
|
445
|
+
],
|
|
446
|
+
}
|
|
447
|
+
if schema is not None:
|
|
448
|
+
payload["target_schema"] = schema.to_dict()
|
|
449
|
+
return payload
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ---------------------------------------------------------------------------
|
|
453
|
+
# Prompt
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
_SYSTEM_PROMPT = """You are CleanFrame's planning assistant. You write a data-cleaning \
|
|
456
|
+
RECIPE as JSON. You never see raw data — only metadata and value patterns. Your recipe \
|
|
457
|
+
is executed deterministically by pure pandas; you cannot run code or touch values.
|
|
458
|
+
|
|
459
|
+
Return ONLY a JSON object (no prose, no markdown fences) matching this shape:
|
|
460
|
+
{
|
|
461
|
+
"version": 1,
|
|
462
|
+
"columns": {
|
|
463
|
+
"<source column name>": {
|
|
464
|
+
"rename_to": "<snake_case name, optional>",
|
|
465
|
+
"ops": [ <ordered ops> ]
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
"dedup": { "subset": ["col"], "keep": "first" }, // optional
|
|
469
|
+
"validate": [ {"column": "col", "check": "valid_email", "on_fail": "quarantine"} ] // optional
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
Each op is either a bare string or a single-key object, e.g. "strip_whitespace",
|
|
473
|
+
{"parse_date": {"formats": ["%d/%m/%Y"]}}, {"remove_symbols": ["₹", ","]},
|
|
474
|
+
{"cast": "float"}, {"normalize_values": {"BLR": "Bangalore"}}.
|
|
475
|
+
|
|
476
|
+
Available ops: {ops}
|
|
477
|
+
|
|
478
|
+
Rules: prefer minimal, safe transforms; do NOT impute missing values; only propose a
|
|
479
|
+
rename when it improves clarity; put ops in a sensible execution order."""
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def build_prompt(metadata: dict) -> tuple[str, str]:
|
|
483
|
+
system = _SYSTEM_PROMPT.replace("{ops}", ", ".join(list_ops()))
|
|
484
|
+
user = "Here is the dataset metadata. Produce the JSON recipe.\n\n" + json.dumps(
|
|
485
|
+
metadata, indent=2, ensure_ascii=False
|
|
486
|
+
)
|
|
487
|
+
return system, user
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
#: Ops the LLM may not emit under stricter modes (rules planner already gates these
|
|
491
|
+
#: via confidence thresholds; the model has no confidence scores to filter on).
|
|
492
|
+
_LLM_BLOCKED_AUTO = frozenset({"fill_na"})
|
|
493
|
+
_LLM_BLOCKED_STRICT = frozenset({"fill_na", "drop_columns"})
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _extract_json_object(text: str) -> str:
|
|
497
|
+
"""Return the first top-level ``{...}`` object using a brace-depth scan.
|
|
498
|
+
|
|
499
|
+
A greedy ``\\{.*\\}`` regex can swallow trailing prose or pick the wrong blob
|
|
500
|
+
when the model wraps JSON in commentary that also contains braces.
|
|
501
|
+
"""
|
|
502
|
+
start = text.find("{")
|
|
503
|
+
if start < 0:
|
|
504
|
+
raise LLMError("LLM response contained no JSON object.")
|
|
505
|
+
depth = 0
|
|
506
|
+
in_str = False
|
|
507
|
+
escape = False
|
|
508
|
+
for i, ch in enumerate(text[start:], start=start):
|
|
509
|
+
if in_str:
|
|
510
|
+
if escape:
|
|
511
|
+
escape = False
|
|
512
|
+
elif ch == "\\":
|
|
513
|
+
escape = True
|
|
514
|
+
elif ch == '"':
|
|
515
|
+
in_str = False
|
|
516
|
+
continue
|
|
517
|
+
if ch == '"':
|
|
518
|
+
in_str = True
|
|
519
|
+
elif ch == "{":
|
|
520
|
+
depth += 1
|
|
521
|
+
elif ch == "}":
|
|
522
|
+
depth -= 1
|
|
523
|
+
if depth == 0:
|
|
524
|
+
return text[start : i + 1]
|
|
525
|
+
raise LLMError("LLM response contained no complete JSON object.")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _finalize_llm_recipe(recipe: Recipe, *, mode: Mode) -> Recipe:
|
|
529
|
+
"""Canonicalise op order and strip mode-inappropriate ops from an LLM recipe."""
|
|
530
|
+
from .planner import _finalize_ops
|
|
531
|
+
|
|
532
|
+
blocked = set()
|
|
533
|
+
if mode is Mode.STRICT:
|
|
534
|
+
blocked = set(_LLM_BLOCKED_STRICT)
|
|
535
|
+
elif mode is Mode.AUTO:
|
|
536
|
+
blocked = set(_LLM_BLOCKED_AUTO)
|
|
537
|
+
|
|
538
|
+
removed: list[str] = []
|
|
539
|
+
for col in recipe.columns:
|
|
540
|
+
ops = [op for op in col.ops if op.name not in blocked]
|
|
541
|
+
removed += [f"{op.name} on {col.source}" for op in col.ops if op.name in blocked]
|
|
542
|
+
col.ops = _finalize_ops(ops)
|
|
543
|
+
removed += [op.name for op in recipe.frame_ops if op.name in blocked]
|
|
544
|
+
recipe.frame_ops = [op for op in recipe.frame_ops if op.name not in blocked]
|
|
545
|
+
if removed:
|
|
546
|
+
recipe.meta["llm_blocked_ops"] = removed
|
|
547
|
+
return recipe
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _sanitize_llm_recipe(data: Any) -> list[str]:
|
|
551
|
+
"""Drop model-written steps a recipe cannot load, returning what was dropped.
|
|
552
|
+
|
|
553
|
+
A hand-written recipe fails loud on a misspelled parameter — that is the point.
|
|
554
|
+
Model output is different: one stray parameter would throw away an otherwise
|
|
555
|
+
good plan, so the bad step is removed and reported instead. The result still
|
|
556
|
+
goes through :meth:`Recipe.from_dict`, so nothing unvalidated reaches the data.
|
|
557
|
+
"""
|
|
558
|
+
from .errors import CleanFrameError
|
|
559
|
+
from .ops import OP_REGISTRY, normalize_op
|
|
560
|
+
from .recipe import _fuse_scalar_arguments
|
|
561
|
+
|
|
562
|
+
dropped: list[str] = []
|
|
563
|
+
if not isinstance(data, dict):
|
|
564
|
+
return dropped
|
|
565
|
+
|
|
566
|
+
def clean_entry(entry: Any, where: str, scope: str) -> Any:
|
|
567
|
+
if isinstance(entry, str):
|
|
568
|
+
name, value = entry, None
|
|
569
|
+
elif isinstance(entry, dict) and len(entry) == 1:
|
|
570
|
+
name, value = next(iter(entry.items()))
|
|
571
|
+
elif (
|
|
572
|
+
isinstance(entry, (list, tuple))
|
|
573
|
+
and 1 <= len(entry) <= 2
|
|
574
|
+
and isinstance(entry[0], str)
|
|
575
|
+
):
|
|
576
|
+
name = entry[0]
|
|
577
|
+
value = entry[1] if len(entry) == 2 else None
|
|
578
|
+
else:
|
|
579
|
+
dropped.append(f"{where}: unrecognised op {entry!r}")
|
|
580
|
+
return None
|
|
581
|
+
spec = OP_REGISTRY.get(str(name))
|
|
582
|
+
if spec is None or spec.scope != scope:
|
|
583
|
+
dropped.append(f"{where}: unusable op {name!r}")
|
|
584
|
+
return None
|
|
585
|
+
if isinstance(value, dict) and not spec.free_form and spec.known_params:
|
|
586
|
+
unknown = sorted(str(k) for k in value if str(k) not in spec.known_params)
|
|
587
|
+
if unknown:
|
|
588
|
+
value = {k: v for k, v in value.items() if str(k) not in unknown}
|
|
589
|
+
dropped.append(f"{where}: {name} parameter(s) {unknown}")
|
|
590
|
+
try:
|
|
591
|
+
normalize_op(str(name), value)
|
|
592
|
+
except CleanFrameError as exc:
|
|
593
|
+
dropped.append(f"{where}: {name} ({exc})")
|
|
594
|
+
return None
|
|
595
|
+
return name if value is None or value == {} else {name: value}
|
|
596
|
+
|
|
597
|
+
columns = data.get("columns")
|
|
598
|
+
if isinstance(columns, dict):
|
|
599
|
+
for column, spec in list(columns.items()):
|
|
600
|
+
if not isinstance(spec, dict):
|
|
601
|
+
continue
|
|
602
|
+
rename = spec.get("rename_to")
|
|
603
|
+
if rename is not None and (not isinstance(rename, str) or not rename.strip()):
|
|
604
|
+
dropped.append(f"column {column!r}: rename_to {rename!r}")
|
|
605
|
+
spec.pop("rename_to")
|
|
606
|
+
raw_ops = spec.get("ops")
|
|
607
|
+
if isinstance(raw_ops, (list, tuple)):
|
|
608
|
+
kept = [
|
|
609
|
+
cleaned
|
|
610
|
+
for entry in _fuse_scalar_arguments(list(raw_ops))
|
|
611
|
+
if (cleaned := clean_entry(entry, f"column {column!r}", "column"))
|
|
612
|
+
is not None
|
|
613
|
+
]
|
|
614
|
+
spec["ops"] = kept
|
|
615
|
+
|
|
616
|
+
raw_frame_ops = data.get("frame_ops")
|
|
617
|
+
if isinstance(raw_frame_ops, (list, tuple)):
|
|
618
|
+
data["frame_ops"] = [
|
|
619
|
+
cleaned
|
|
620
|
+
for entry in _fuse_scalar_arguments(list(raw_frame_ops))
|
|
621
|
+
if (cleaned := clean_entry(entry, "frame_ops", "frame")) is not None
|
|
622
|
+
]
|
|
623
|
+
|
|
624
|
+
rules = data.get("validate")
|
|
625
|
+
if isinstance(rules, (list, tuple)):
|
|
626
|
+
from .recipe import ValidationRule
|
|
627
|
+
|
|
628
|
+
kept_rules = []
|
|
629
|
+
for rule in rules:
|
|
630
|
+
try:
|
|
631
|
+
ValidationRule.from_dict(rule)
|
|
632
|
+
except CleanFrameError as exc:
|
|
633
|
+
dropped.append(f"validation {rule!r} ({exc})")
|
|
634
|
+
continue
|
|
635
|
+
kept_rules.append(rule)
|
|
636
|
+
data["validate"] = kept_rules
|
|
637
|
+
|
|
638
|
+
return dropped
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def parse_recipe_json(text: str) -> Recipe:
|
|
642
|
+
"""Extract and validate a Recipe from the model's response.
|
|
643
|
+
|
|
644
|
+
Validation runs through :meth:`Recipe.from_dict`, so an unknown op or malformed
|
|
645
|
+
structure never reaches the data. Steps the model wrote that cannot load are
|
|
646
|
+
dropped and listed in ``recipe.meta["llm_dropped"]`` rather than discarding the
|
|
647
|
+
whole plan.
|
|
648
|
+
"""
|
|
649
|
+
text = text.strip()
|
|
650
|
+
if text.startswith("```"):
|
|
651
|
+
text = text.strip("`")
|
|
652
|
+
text = text[text.find("{") :] if "{" in text else text
|
|
653
|
+
blob = _extract_json_object(text)
|
|
654
|
+
try:
|
|
655
|
+
data = json.loads(blob)
|
|
656
|
+
except json.JSONDecodeError as exc:
|
|
657
|
+
raise LLMError(f"LLM returned invalid JSON: {exc}") from exc
|
|
658
|
+
dropped = _sanitize_llm_recipe(data)
|
|
659
|
+
try:
|
|
660
|
+
recipe = Recipe.from_dict(data)
|
|
661
|
+
except LLMError:
|
|
662
|
+
raise
|
|
663
|
+
except Exception as exc: # noqa: BLE001 - any validation failure => a clean LLMError
|
|
664
|
+
# Wrap RecipeError and any stray KeyError/TypeError from a malformed model
|
|
665
|
+
# recipe so the caller only ever sees LLMError (and the planner falls back).
|
|
666
|
+
raise LLMError(f"LLM produced an invalid recipe: {exc}") from exc
|
|
667
|
+
if dropped:
|
|
668
|
+
recipe.meta["llm_dropped"] = dropped
|
|
669
|
+
return recipe
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
# ---------------------------------------------------------------------------
|
|
673
|
+
# Planner
|
|
674
|
+
# ---------------------------------------------------------------------------
|
|
675
|
+
class LLMPlanner:
|
|
676
|
+
"""A :class:`~cleanframe.planner.Planner` that asks an LLM for the recipe.
|
|
677
|
+
|
|
678
|
+
Falls back to the rules planner on any failure (unless ``fallback=None``), so an
|
|
679
|
+
LLM outage or a bad key degrades to deterministic rules rather than an error.
|
|
680
|
+
"""
|
|
681
|
+
|
|
682
|
+
def __init__(
|
|
683
|
+
self,
|
|
684
|
+
client: LLMClient,
|
|
685
|
+
*,
|
|
686
|
+
exposure: LLMExposure | str = LLMExposure.METADATA,
|
|
687
|
+
max_tokens_budget: int | None = None,
|
|
688
|
+
max_output_tokens: int = 2048,
|
|
689
|
+
fallback: Any | None = "rules",
|
|
690
|
+
) -> None:
|
|
691
|
+
self.client = client
|
|
692
|
+
self.exposure = LLMExposure(str(exposure)) if not isinstance(exposure, LLMExposure) else exposure
|
|
693
|
+
self.max_tokens_budget = max_tokens_budget
|
|
694
|
+
self.max_output_tokens = max_output_tokens
|
|
695
|
+
self.fallback = fallback
|
|
696
|
+
self.last_response: LLMResponse | None = None
|
|
697
|
+
|
|
698
|
+
def plan(
|
|
699
|
+
self,
|
|
700
|
+
df: pd.DataFrame,
|
|
701
|
+
profile: DataFrameProfile,
|
|
702
|
+
issues: Issues,
|
|
703
|
+
*,
|
|
704
|
+
schema: Any | None = None,
|
|
705
|
+
mode: Mode | str = Mode.REVIEW,
|
|
706
|
+
options: dict[str, Any] | None = None,
|
|
707
|
+
) -> Recipe:
|
|
708
|
+
from .fingerprint import fingerprint_dataframe
|
|
709
|
+
|
|
710
|
+
mode = Mode.coerce(mode)
|
|
711
|
+
if self.exposure is LLMExposure.NONE:
|
|
712
|
+
warnings.warn(
|
|
713
|
+
"CleanFrame: exposure='none' keeps everything local, so no request was "
|
|
714
|
+
"made — planning with deterministic rules instead.",
|
|
715
|
+
CleanFrameWarning,
|
|
716
|
+
stacklevel=2,
|
|
717
|
+
)
|
|
718
|
+
recipe = self._fallback().plan(
|
|
719
|
+
df, profile, issues, schema=schema, mode=mode, options=options
|
|
720
|
+
)
|
|
721
|
+
recipe.meta["llm_exposure"] = "none"
|
|
722
|
+
return recipe
|
|
723
|
+
try:
|
|
724
|
+
recipe = self._plan_via_llm(df, profile, issues, schema, mode=mode)
|
|
725
|
+
except Exception as exc: # noqa: BLE001
|
|
726
|
+
# The promise is "any failure degrades to deterministic rules so the
|
|
727
|
+
# pipeline never dies because an LLM was flaky" — that must hold for a
|
|
728
|
+
# bad key, over-budget, malformed output, AND a provider/network error,
|
|
729
|
+
# not just LLMError/BudgetExceeded. (KeyboardInterrupt/SystemExit are
|
|
730
|
+
# BaseException and correctly propagate.)
|
|
731
|
+
if self.fallback is None:
|
|
732
|
+
if isinstance(exc, LLMError):
|
|
733
|
+
raise
|
|
734
|
+
raise LLMError(f"LLM planning failed: {exc}") from exc
|
|
735
|
+
warnings.warn(
|
|
736
|
+
f"CleanFrame: LLM planning failed ({exc}); falling back to rules planner. "
|
|
737
|
+
"Inspect recipe.meta['llm_fallback'] for details.",
|
|
738
|
+
CleanFrameWarning,
|
|
739
|
+
stacklevel=2,
|
|
740
|
+
)
|
|
741
|
+
recipe = self._fallback().plan(
|
|
742
|
+
df, profile, issues, schema=schema, mode=mode, options=options
|
|
743
|
+
)
|
|
744
|
+
recipe.meta["llm_fallback"] = str(exc)
|
|
745
|
+
return recipe
|
|
746
|
+
|
|
747
|
+
if recipe.source_fingerprint is None:
|
|
748
|
+
recipe.source_fingerprint = fingerprint_dataframe(df)
|
|
749
|
+
recipe.stamp_meta(
|
|
750
|
+
generated_by=f"llm:{self.client.model}",
|
|
751
|
+
mode=mode.value,
|
|
752
|
+
llm_exposure=self.exposure.value,
|
|
753
|
+
)
|
|
754
|
+
return recipe
|
|
755
|
+
|
|
756
|
+
def _plan_via_llm(self, df, profile, issues, schema, *, mode: Mode) -> Recipe:
|
|
757
|
+
metadata = build_metadata(df, profile, issues, schema, self.exposure)
|
|
758
|
+
system, user = build_prompt(metadata)
|
|
759
|
+
if self.max_tokens_budget is not None:
|
|
760
|
+
estimate = _estimate_tokens(system) + _estimate_tokens(user) + self.max_output_tokens
|
|
761
|
+
if estimate > self.max_tokens_budget:
|
|
762
|
+
raise BudgetExceeded(
|
|
763
|
+
f"Estimated {estimate} tokens exceeds max_tokens_budget={self.max_tokens_budget}."
|
|
764
|
+
)
|
|
765
|
+
response = self.client.complete(system, user, max_tokens=self.max_output_tokens)
|
|
766
|
+
self.last_response = response # noqa: RUF100
|
|
767
|
+
if self.max_tokens_budget is not None and response.total_tokens > self.max_tokens_budget:
|
|
768
|
+
raise BudgetExceeded(
|
|
769
|
+
f"Used {response.total_tokens} tokens, over max_tokens_budget={self.max_tokens_budget}."
|
|
770
|
+
)
|
|
771
|
+
recipe = _finalize_llm_recipe(parse_recipe_json(response.text), mode=mode)
|
|
772
|
+
dropped = recipe.meta.get("llm_dropped")
|
|
773
|
+
if dropped:
|
|
774
|
+
warnings.warn(
|
|
775
|
+
f"CleanFrame: {len(dropped)} step(s) the model wrote could not be used and "
|
|
776
|
+
f"were dropped ({'; '.join(dropped[:3])}"
|
|
777
|
+
+ (", …)" if len(dropped) > 3 else ")")
|
|
778
|
+
+ ". See recipe.meta['llm_dropped'].",
|
|
779
|
+
CleanFrameWarning,
|
|
780
|
+
stacklevel=2,
|
|
781
|
+
)
|
|
782
|
+
return recipe
|
|
783
|
+
|
|
784
|
+
def _fallback(self):
|
|
785
|
+
from .planner import RulesPlanner
|
|
786
|
+
|
|
787
|
+
if self.fallback is None or self.fallback in ("rules", True):
|
|
788
|
+
return RulesPlanner()
|
|
789
|
+
return self.fallback
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _estimate_tokens(text: str) -> int:
|
|
793
|
+
# ~4 chars/token is a good enough pre-flight estimate for budgeting.
|
|
794
|
+
return max(1, len(text) // 4)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
__all__ = [
|
|
798
|
+
"LLMClient",
|
|
799
|
+
"LLMResponse",
|
|
800
|
+
"LLMPlanner",
|
|
801
|
+
"AnthropicClient",
|
|
802
|
+
"OpenAIClient",
|
|
803
|
+
"ProviderSpec",
|
|
804
|
+
"PROVIDERS",
|
|
805
|
+
"get_client",
|
|
806
|
+
"list_providers",
|
|
807
|
+
"build_metadata",
|
|
808
|
+
"build_prompt",
|
|
809
|
+
"parse_recipe_json",
|
|
810
|
+
"value_sketches",
|
|
811
|
+
]
|