learnstat 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
learnstat/llm.py ADDED
@@ -0,0 +1,280 @@
1
+ """Optional AI backend (any OpenAI-compatible chat endpoint).
2
+
3
+ learnstat works fully without this module: profiling, rule-based advice,
4
+ explanations and mechanical error recovery are all offline. When an API key
5
+ (or a custom endpoint) is configured - see :mod:`learnstat.config` - the model
6
+ is used to
7
+
8
+ * tailor /describe, /alternatives and /learn explanations to the dataset,
9
+ * review code for issues the rules do not cover, and
10
+ * propose corrections for errors the rules cannot fix (these always require
11
+ the user's confirmation and pass the same validation as rule-based fixes).
12
+
13
+ Every call degrades gracefully: on any error the caller gets ``None`` and falls
14
+ back to the offline behaviour. Only the standard library is used for HTTP.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ import socket
22
+ import urllib.error
23
+ import urllib.request
24
+
25
+ from . import config as _config
26
+
27
+ SYSTEM_PROMPT = """You are learnstat, a statistics and Python mentor embedded in a learning library.
28
+ The user writes and owns their analytical code. Your job is to help them learn: explain reasoning,
29
+ point out statistical and visualisation pitfalls, and recommend standard pandas / NumPy / SciPy /
30
+ Matplotlib functions with their real names, so the skills transfer to the wider ecosystem.
31
+
32
+ Principles:
33
+ - Ground every statement in the dataset profile and code you are given; say when something is a guess.
34
+ - Never silently change the user's data, assumptions, statistical method or objective. When a change
35
+ would do that, present it as an option and explain the trade-off.
36
+ - Prefer clear explanations over jargon; name the statistical concept, its purpose, when it is
37
+ appropriate and when an alternative is better.
38
+ - Keep answers concise and practical, with short code snippets using standard library APIs."""
39
+
40
+ TIMEOUT_SECONDS = 120
41
+
42
+
43
+ class LLMAssistant:
44
+ def __init__(self, cfg: _config.AIConfig | None = None):
45
+ self.cfg = cfg or _config.resolve()
46
+ self.last_error: str | None = None
47
+ self.disabled = False # set after an unrecoverable failure (e.g. bad credentials)
48
+
49
+ @property
50
+ def model(self) -> str:
51
+ return self.cfg.model
52
+
53
+ # -- availability -----------------------------------------------------
54
+ def available(self) -> bool:
55
+ if self.cfg.needs_key and not self.cfg.api_key:
56
+ self.last_error = "no AI is set up yet (run: learnstat setup)"
57
+ return False
58
+ return True
59
+
60
+ # -- low-level --------------------------------------------------------
61
+ def _create(self, prompt: str, max_tokens: int = 4000, json_schema: dict | None = None,
62
+ timeout: float = TIMEOUT_SECONDS) -> str | None:
63
+ if self.disabled:
64
+ return None
65
+ if json_schema:
66
+ prompt += (
67
+ "\n\nReply with ONLY a JSON object (no prose, no code fences) matching this JSON Schema:\n"
68
+ + json.dumps(json_schema)
69
+ )
70
+ body = {
71
+ "model": self.cfg.model,
72
+ "max_tokens": max_tokens,
73
+ "messages": [
74
+ {"role": "system", "content": SYSTEM_PROMPT},
75
+ {"role": "user", "content": prompt},
76
+ ],
77
+ }
78
+ try:
79
+ payload = _request(self.cfg.chat_url, self.cfg.headers, body, timeout)
80
+ except AIError as e:
81
+ self.last_error = str(e)
82
+ if e.fatal:
83
+ self.disabled = True
84
+ return None
85
+ try:
86
+ text = payload["choices"][0]["message"]["content"] or ""
87
+ except (KeyError, IndexError, TypeError):
88
+ self.last_error = "unexpected response format - is the base URL an OpenAI-compatible /chat/completions endpoint?"
89
+ return None
90
+ text = text.strip()
91
+ if not text:
92
+ self.last_error = "the model returned an empty response"
93
+ return None
94
+ return text
95
+
96
+ def _json(self, prompt: str, schema: dict, max_tokens: int = 4000) -> dict | None:
97
+ text = self._create(prompt, max_tokens=max_tokens, json_schema=schema)
98
+ if not text:
99
+ return None
100
+ data = _parse_json_object(text)
101
+ if data is None:
102
+ self.last_error = "the response was not valid JSON"
103
+ return data
104
+
105
+ def test(self, timeout: float = 30) -> str | None:
106
+ """Send a tiny request; returns the reply, or None (see ``last_error``)."""
107
+ self.disabled = False
108
+ return self._create("Reply with the single word: ok", max_tokens=20, timeout=timeout)
109
+
110
+ # -- features ---------------------------------------------------------
111
+ def explain(self, kind: str, suggestion_text: str, grounding: str, profile_md: str, code: str) -> str | None:
112
+ task = {
113
+ "describe": "Explain the reasoning behind this suggestion: the statistical concept, the purpose of the "
114
+ "suggested function, why it suits THIS dataset and objective, and when another method "
115
+ "would be preferable.",
116
+ "alternatives": "List 3-5 alternative approaches to the same analytical goal, each with a one-line "
117
+ "trade-off and a short snippet, ordered from most to least recommended for this dataset.",
118
+ "learn": "Write a short lesson (under 300 words) on the concept behind this suggestion, using the user's "
119
+ "own columns in the examples, ending with one hands-on exercise on their data.",
120
+ }[kind]
121
+ prompt = (
122
+ f"{task}\n\n<suggestion>\n{suggestion_text}\n</suggestion>\n\n"
123
+ f"<reference_explanation>\n{grounding}\n</reference_explanation>\n\n"
124
+ f"<dataset_profile>\n{profile_md}\n</dataset_profile>\n\n<user_code>\n{code}\n</user_code>"
125
+ )
126
+ return self._create(prompt, max_tokens=4000)
127
+
128
+ def ask(self, question: str, profile_md: str, code: str) -> str | None:
129
+ prompt = (
130
+ f"<dataset_profile>\n{profile_md}\n</dataset_profile>\n\n<recent_code>\n{code}\n</recent_code>\n\n"
131
+ f"Question from the user: {question}"
132
+ )
133
+ return self._create(prompt, max_tokens=4000)
134
+
135
+ REVIEW_SCHEMA = {
136
+ "type": "object",
137
+ "properties": {
138
+ "suggestions": {
139
+ "type": "array",
140
+ "items": {
141
+ "type": "object",
142
+ "properties": {
143
+ "line": {"type": "integer"},
144
+ "title": {"type": "string"},
145
+ "problem": {"type": "string"},
146
+ "alternative": {"type": "string"},
147
+ "rationale": {"type": "string"},
148
+ "code": {"type": "string"},
149
+ },
150
+ "required": ["line", "title", "problem", "alternative", "rationale", "code"],
151
+ },
152
+ }
153
+ },
154
+ "required": ["suggestions"],
155
+ }
156
+
157
+ def review(self, code: str, profile_md: str, already: list[str]) -> list[dict] | None:
158
+ prompt = (
159
+ "Review the user's analysis code against the dataset profile. Report only concrete, important "
160
+ "statistical or visualisation issues (wrong chart for the data, violated test assumptions, misleading "
161
+ "summaries, data-quality traps). Do not repeat these already-reported issues: "
162
+ f"{already or 'none'}. Return an empty list if nothing important remains. Keep each field to one or "
163
+ "two sentences; `code` is a short replacement snippet using standard pandas/SciPy/Matplotlib APIs.\n\n"
164
+ f"<dataset_profile>\n{profile_md}\n</dataset_profile>\n\n<user_code>\n{code}\n</user_code>"
165
+ )
166
+ data = self._json(prompt, self.REVIEW_SCHEMA)
167
+ if not data or not isinstance(data.get("suggestions"), list):
168
+ return None
169
+ return data["suggestions"]
170
+
171
+ FIX_SCHEMA = {
172
+ "type": "object",
173
+ "properties": {
174
+ "can_fix": {"type": "boolean"},
175
+ "explanation": {"type": "string"},
176
+ "fixed_code": {"type": "string"},
177
+ },
178
+ "required": ["can_fix", "explanation", "fixed_code"],
179
+ }
180
+
181
+ def propose_fix(self, statement: str, error: str, category: str, context: str) -> dict | None:
182
+ prompt = (
183
+ "A statement in the user's analysis raised an error. Propose the smallest correction to THIS statement "
184
+ "only. Hard constraints: do not change the dataset (no dropna/fillna/astype/replace/filters), do not "
185
+ "change the statistical method or its options, do not change which variables are assigned, do not add "
186
+ "file, network or shell operations. If the error can only be solved by one of those, set can_fix=false "
187
+ "and explain what the user should decide. `fixed_code` must be the complete corrected statement.\n\n"
188
+ f"<error category=\"{category}\">\n{error}\n</error>\n\n<statement>\n{statement}\n</statement>\n\n"
189
+ f"<context>\n{context}\n</context>"
190
+ )
191
+ data = self._json(prompt, self.FIX_SCHEMA, max_tokens=4000)
192
+ if not data or not all(k in data for k in ("can_fix", "explanation", "fixed_code")):
193
+ return None
194
+ return data
195
+
196
+
197
+ class AIError(Exception):
198
+ """A failed call, with a message written for the user. ``fatal`` = retrying cannot help (bad key)."""
199
+
200
+ def __init__(self, message: str, status: int | None = None, fatal: bool = False):
201
+ super().__init__(message)
202
+ self.status = status
203
+ self.fatal = fatal
204
+
205
+
206
+ def _error_detail(e: urllib.error.HTTPError) -> str:
207
+ try:
208
+ data = json.loads(e.read().decode("utf-8", "replace"))
209
+ err = data.get("error", data) if isinstance(data, dict) else data
210
+ if isinstance(err, list) and err: # e.g. Gemini wraps errors in a list
211
+ err = err[0].get("error", err[0]) if isinstance(err[0], dict) else err[0]
212
+ return str(err.get("message", err)) if isinstance(err, dict) else str(err)
213
+ except Exception:
214
+ return str(e.reason)
215
+
216
+
217
+ def _friendly(status: int, detail: str) -> str:
218
+ if status in (401, 403):
219
+ return f"the API key was rejected ({status}) - check it is correct, active and allowed to use this model. Provider says: {detail}"
220
+ if status == 404:
221
+ return f"endpoint or model not found (404) - check the base URL and the model name. Provider says: {detail}"
222
+ if status == 429:
223
+ return f"rate limit or quota exceeded (429) - check your plan/billing. Provider says: {detail}"
224
+ if status == 400:
225
+ return f"the provider rejected the request (400): {detail}"
226
+ if status >= 500:
227
+ return f"the provider had a server error ({status}); try again shortly. {detail}"
228
+ return f"API error {status}: {detail}"
229
+
230
+
231
+ def _request(url: str, headers: dict, body: dict | None = None, timeout: float = TIMEOUT_SECONDS) -> dict:
232
+ """POST ``body`` (or GET when None) and return the decoded JSON, raising :class:`AIError`."""
233
+ data = json.dumps(body).encode("utf-8") if body is not None else None
234
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST" if body is not None else "GET")
235
+ try:
236
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
237
+ return json.loads(resp.read().decode("utf-8"))
238
+ except urllib.error.HTTPError as e:
239
+ raise AIError(_friendly(e.code, _error_detail(e)), status=e.code, fatal=e.code in (401, 403)) from None
240
+ except urllib.error.URLError as e:
241
+ reason = e.reason
242
+ if isinstance(reason, socket.timeout):
243
+ raise AIError(f"timed out after {timeout:g}s waiting for {url}") from None
244
+ raise AIError(f"could not connect to {url} ({reason}) - check the address and your internet connection") from None
245
+ except (socket.timeout, TimeoutError):
246
+ raise AIError(f"timed out after {timeout:g}s waiting for {url}") from None
247
+ except ValueError:
248
+ raise AIError(f"{url} did not return JSON - is it an OpenAI-compatible endpoint?") from None
249
+ except OSError as e:
250
+ raise AIError(f"network error: {e}") from None
251
+
252
+
253
+ def fetch_models(cfg: _config.AIConfig, timeout: float = 20) -> list[str]:
254
+ """Ask the provider which models this key can use. Raises :class:`AIError`."""
255
+ payload = _request(cfg.models_url, cfg.headers, None, timeout)
256
+ items = payload.get("data", payload.get("models", [])) if isinstance(payload, dict) else payload
257
+ ids = []
258
+ for item in items or []:
259
+ mid = item.get("id") or item.get("name") if isinstance(item, dict) else item
260
+ if isinstance(mid, str) and mid:
261
+ ids.append(mid[len("models/"):] if mid.startswith("models/") else mid)
262
+ if not ids:
263
+ raise AIError("the provider returned no models for this key")
264
+ return sorted(dict.fromkeys(ids))
265
+
266
+
267
+ def _parse_json_object(text: str) -> dict | None:
268
+ """Parse a JSON object from model output, tolerating code fences and surrounding prose."""
269
+ text = text.strip()
270
+ fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL)
271
+ if fence:
272
+ text = fence.group(1)
273
+ for candidate in (text, text[text.find("{"): text.rfind("}") + 1] if "{" in text else ""):
274
+ try:
275
+ data = json.loads(candidate)
276
+ except ValueError:
277
+ continue
278
+ if isinstance(data, dict):
279
+ return data
280
+ return None
learnstat/memory.py ADDED
@@ -0,0 +1,193 @@
1
+ """Persistent Markdown memory for each dataset.
2
+
3
+ Two files are kept per dataset in the memory directory (``.learnstat/`` by
4
+ default):
5
+
6
+ * ``<name>.md`` - the human-readable profile the user can read and edit.
7
+ * ``<name>.json`` - a machine-readable cache (profile + fingerprint +
8
+ decisions) so the dataset is only re-profiled when its content changes.
9
+
10
+ User-written notes and analytical decisions survive re-profiling.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ import pandas as pd
22
+
23
+ from .profile import DatasetProfile, fingerprint, profile_dataframe
24
+
25
+
26
+ def _slug(name: str) -> str:
27
+ return re.sub(r"[^A-Za-z0-9_.-]+", "_", name).strip("_") or "dataset"
28
+
29
+
30
+ def _now() -> str:
31
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
32
+
33
+
34
+ @dataclass
35
+ class DatasetMemory:
36
+ profile: DatasetProfile
37
+ decisions: list[dict[str, str]] = field(default_factory=list)
38
+ notes: list[dict[str, str]] = field(default_factory=list)
39
+ reused: bool = False # True when loaded from cache without re-profiling
40
+
41
+ def to_dict(self) -> dict:
42
+ return {"profile": self.profile.to_dict(), "decisions": self.decisions, "notes": self.notes}
43
+
44
+
45
+ class MemoryStore:
46
+ def __init__(self, directory: str | Path = ".learnstat"):
47
+ self.directory = Path(directory)
48
+
49
+ # -- paths ------------------------------------------------------------
50
+ def md_path(self, name: str) -> Path:
51
+ return self.directory / f"{_slug(name)}.md"
52
+
53
+ def json_path(self, name: str) -> Path:
54
+ return self.directory / f"{_slug(name)}.json"
55
+
56
+ # -- load / save ------------------------------------------------------
57
+ def load(self, name: str) -> DatasetMemory | None:
58
+ path = self.json_path(name)
59
+ if not path.exists():
60
+ return None
61
+ try:
62
+ data = json.loads(path.read_text(encoding="utf-8"))
63
+ return DatasetMemory(
64
+ profile=DatasetProfile.from_dict(data["profile"]),
65
+ decisions=data.get("decisions", []),
66
+ notes=data.get("notes", []),
67
+ )
68
+ except (json.JSONDecodeError, KeyError, TypeError):
69
+ return None # corrupt cache: fall back to re-profiling
70
+
71
+ def load_all(self) -> list[DatasetMemory]:
72
+ if not self.directory.exists():
73
+ return []
74
+ out = []
75
+ for p in sorted(self.directory.glob("*.json")):
76
+ mem = self.load(p.stem)
77
+ if mem:
78
+ out.append(mem)
79
+ return out
80
+
81
+ def save(self, mem: DatasetMemory) -> Path:
82
+ self.directory.mkdir(parents=True, exist_ok=True)
83
+ name = mem.profile.name
84
+ self.json_path(name).write_text(json.dumps(mem.to_dict(), indent=2, default=str), encoding="utf-8")
85
+ md = self.md_path(name)
86
+ md.write_text(render_markdown(mem), encoding="utf-8")
87
+ return md
88
+
89
+ def load_or_profile(
90
+ self, df: pd.DataFrame, name: str, source: str | None = None, force: bool = False
91
+ ) -> DatasetMemory:
92
+ """Return the memory for ``df``; re-profile only if the content changed."""
93
+ existing = self.load(name)
94
+ if existing and not force and existing.profile.fingerprint == fingerprint(df):
95
+ existing.reused = True
96
+ return existing
97
+ profile = profile_dataframe(df, name=name, source=source)
98
+ mem = DatasetMemory(profile=profile)
99
+ if existing: # keep the user's history across data changes
100
+ mem.decisions = existing.decisions
101
+ mem.notes = existing.notes
102
+ if existing.profile.fingerprint != profile.fingerprint:
103
+ mem.notes.append({"at": _now(), "text": "Dataset content changed; profile regenerated."})
104
+ self.save(mem)
105
+ return mem
106
+
107
+ # -- updates ----------------------------------------------------------
108
+ def record_decision(self, mem: DatasetMemory, text: str) -> None:
109
+ mem.decisions.append({"at": _now(), "text": text})
110
+ self.save(mem)
111
+
112
+ def add_note(self, mem: DatasetMemory, text: str) -> None:
113
+ mem.notes.append({"at": _now(), "text": text})
114
+ self.save(mem)
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ def render_markdown(mem: DatasetMemory) -> str:
121
+ p = mem.profile
122
+ lines: list[str] = [
123
+ f"# Dataset profile: {p.name}",
124
+ "",
125
+ f"- Source: `{p.source}`" if p.source else "- Source: in-memory DataFrame",
126
+ f"- Shape: {p.n_rows} rows x {p.n_cols} columns",
127
+ f"- Complete rows (no missing values): {p.n_complete_rows}",
128
+ f"- Duplicate rows: {p.n_duplicate_rows}",
129
+ f"- Fingerprint: `{p.fingerprint}` (profiled {p.created_at})",
130
+ "",
131
+ "## Columns",
132
+ "",
133
+ "| Column | Kind | dtype | Missing | Unique | Likely meaning (guess) |",
134
+ "|---|---|---|---|---|---|",
135
+ ]
136
+ for c in p.columns.values():
137
+ lines.append(
138
+ f"| `{c.name}` | {c.kind} | {c.dtype} | {c.n_missing} ({c.pct_missing}%) | {c.n_unique} | "
139
+ f"{c.inferred_meaning or '-'} |"
140
+ )
141
+
142
+ num = [c for c in p.columns.values() if c.kind == "numeric"]
143
+ if num:
144
+ lines += ["", "## Numerical variables", ""]
145
+ for c in num:
146
+ s = c.summary
147
+ lines.append(
148
+ f"- `{c.name}`: mean={s.get('mean')}, median={s.get('median')}, sd={s.get('std')}, "
149
+ f"range=[{s.get('min')}, {s.get('max')}], skew={s.get('skew')}"
150
+ )
151
+ lines += [f" - {n}" for n in c.notes]
152
+
153
+ cat = [c for c in p.columns.values() if c.kind in ("categorical", "boolean")]
154
+ if cat:
155
+ lines += ["", "## Categorical variables", ""]
156
+ for c in cat:
157
+ top = c.summary.get("top_values")
158
+ top_s = ", ".join(f"{k} ({v})" for k, v in top.items()) if top else ""
159
+ lines.append(f"- `{c.name}`: {c.n_unique} levels" + (f"; most common: {top_s}" if top_s else ""))
160
+ lines += [f" - {n}" for n in c.notes]
161
+
162
+ other = [c for c in p.columns.values() if c.kind in ("datetime", "text", "identifier")]
163
+ if other:
164
+ lines += ["", "## Other columns", ""]
165
+ for c in other:
166
+ lines.append(f"- `{c.name}` ({c.kind})")
167
+ lines += [f" - {n}" for n in c.notes]
168
+
169
+ if p.correlations:
170
+ lines += ["", "## Notable relationships (|Pearson r| >= 0.5)", ""]
171
+ lines += [f"- `{d['a']}` ~ `{d['b']}`: r = {d['r']}" for d in p.correlations[:15]]
172
+
173
+ lines += ["", "## Data-quality issues", ""]
174
+ lines += [f"- {i}" for i in p.issues] or ["- None detected by automatic checks."]
175
+
176
+ lines += ["", "## Potentially useful analyses and visualisations", ""]
177
+ lines += [f"- {s}" for s in p.suggested_analyses] or ["- (none yet)"]
178
+
179
+ lines += [
180
+ "",
181
+ "## Assumptions and limitations",
182
+ "",
183
+ "- Column meanings are inferred from names only; verify them against the data dictionary.",
184
+ "- Outliers are flagged with the 1.5xIQR rule; flagged does not mean wrong.",
185
+ "- Correlations are Pearson on pairwise-complete rows and say nothing about causation.",
186
+ "",
187
+ "## Analytical decisions",
188
+ "",
189
+ ]
190
+ lines += [f"- {d['at']}: {d['text']}" for d in mem.decisions] or ["- (none recorded yet)"]
191
+ lines += ["", "## Notes", ""]
192
+ lines += [f"- {n['at']}: {n['text']}" for n in mem.notes] or ["- (none)"]
193
+ return "\n".join(lines) + "\n"