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.
Files changed (46) hide show
  1. cleanframe/__init__.py +169 -0
  2. cleanframe/__main__.py +5 -0
  3. cleanframe/_util.py +438 -0
  4. cleanframe/_version.py +1 -0
  5. cleanframe/api.py +559 -0
  6. cleanframe/cli.py +688 -0
  7. cleanframe/codegen.py +666 -0
  8. cleanframe/dataio.py +506 -0
  9. cleanframe/detectors/__init__.py +43 -0
  10. cleanframe/detectors/base.py +221 -0
  11. cleanframe/detectors/categories.py +199 -0
  12. cleanframe/detectors/contacts.py +105 -0
  13. cleanframe/detectors/currency.py +112 -0
  14. cleanframe/detectors/dates.py +204 -0
  15. cleanframe/detectors/dedup.py +150 -0
  16. cleanframe/detectors/nulls.py +109 -0
  17. cleanframe/detectors/outliers.py +73 -0
  18. cleanframe/detectors/schema_mapping.py +125 -0
  19. cleanframe/detectors/text.py +105 -0
  20. cleanframe/detectors/units.py +86 -0
  21. cleanframe/diff.py +369 -0
  22. cleanframe/drift.py +283 -0
  23. cleanframe/errors.py +66 -0
  24. cleanframe/executor.py +229 -0
  25. cleanframe/fingerprint.py +83 -0
  26. cleanframe/issues.py +186 -0
  27. cleanframe/llm.py +811 -0
  28. cleanframe/ops.py +1245 -0
  29. cleanframe/planner.py +353 -0
  30. cleanframe/profile.py +413 -0
  31. cleanframe/py.typed +1 -0
  32. cleanframe/quality.py +81 -0
  33. cleanframe/readfix.py +160 -0
  34. cleanframe/recipe.py +398 -0
  35. cleanframe/report.py +345 -0
  36. cleanframe/result.py +144 -0
  37. cleanframe/schema.py +259 -0
  38. cleanframe/streaming.py +354 -0
  39. cleanframe/types.py +119 -0
  40. cleanframe/validate.py +363 -0
  41. cleanframe/workbook.py +370 -0
  42. cleanframe_engine-0.3.0.dist-info/METADATA +323 -0
  43. cleanframe_engine-0.3.0.dist-info/RECORD +46 -0
  44. cleanframe_engine-0.3.0.dist-info/WHEEL +4 -0
  45. cleanframe_engine-0.3.0.dist-info/entry_points.txt +2 -0
  46. 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
+ ]