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/__init__.py +80 -0
- learnstat/advisor.py +502 -0
- learnstat/cli.py +338 -0
- learnstat/commands.py +317 -0
- learnstat/config.py +223 -0
- learnstat/ipython_ext.py +104 -0
- learnstat/knowledge.py +233 -0
- learnstat/llm.py +280 -0
- learnstat/memory.py +193 -0
- learnstat/profile.py +344 -0
- learnstat/providers.py +78 -0
- learnstat/recovery.py +1021 -0
- learnstat/safety.py +100 -0
- learnstat/session.py +307 -0
- learnstat/setup.py +308 -0
- learnstat/suggestions.py +64 -0
- learnstat-0.2.0.dist-info/METADATA +199 -0
- learnstat-0.2.0.dist-info/RECORD +22 -0
- learnstat-0.2.0.dist-info/WHEEL +5 -0
- learnstat-0.2.0.dist-info/entry_points.txt +2 -0
- learnstat-0.2.0.dist-info/licenses/LICENSE +21 -0
- learnstat-0.2.0.dist-info/top_level.txt +1 -0
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"
|