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/cli.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Command-line interface: ``learnstat`` (guided start), ``setup``, ``profile|check|watch|run|repl|config``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import codeop
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .commands import is_command
|
|
13
|
+
from .session import Session, set_session
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _confirm(question: str) -> bool:
|
|
17
|
+
try:
|
|
18
|
+
return input(f"{question} [y/N] ").strip().lower() in ("y", "yes")
|
|
19
|
+
except EOFError:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _session(args, interactive: bool = True) -> Session:
|
|
24
|
+
confirm = (lambda q: True) if getattr(args, "yes", False) else (_confirm if interactive else None)
|
|
25
|
+
s = Session(
|
|
26
|
+
memory_dir=args.memory_dir,
|
|
27
|
+
ai=False if args.no_ai else "auto",
|
|
28
|
+
max_attempts=getattr(args, "max_attempts", 3),
|
|
29
|
+
auto_fix=not getattr(args, "no_fix", False),
|
|
30
|
+
confirm=confirm,
|
|
31
|
+
ai_review=getattr(args, "ai_review", False),
|
|
32
|
+
)
|
|
33
|
+
return set_session(s)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_profile(args) -> int:
|
|
37
|
+
s = _session(args)
|
|
38
|
+
s.verbose = False
|
|
39
|
+
df = s.read_csv(args.data, name=args.name) if not args.data.endswith((".xlsx", ".xls")) else s.read_excel(args.data, name=args.name)
|
|
40
|
+
del df
|
|
41
|
+
print(s.memory.md_path(s.profile().name).read_text(encoding="utf-8"))
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _static_check(s: Session, script: Path, data: list[str]) -> list:
|
|
46
|
+
code = script.read_text(encoding="utf-8")
|
|
47
|
+
frames = s.profiles_for_code(code, base_dir=script.parent)
|
|
48
|
+
for d in data or []:
|
|
49
|
+
s.verbose = False
|
|
50
|
+
s.read_csv(d)
|
|
51
|
+
return s.check(code, show=False, extra_frames=frames)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_check(args) -> int:
|
|
55
|
+
s = _session(args, interactive=False)
|
|
56
|
+
found = _static_check(s, Path(args.script), args.data)
|
|
57
|
+
if not found:
|
|
58
|
+
print("No suggestions - nothing the rules flag for this data.")
|
|
59
|
+
for sug in found:
|
|
60
|
+
print(sug.render())
|
|
61
|
+
print()
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_watch(args) -> int:
|
|
66
|
+
s = _session(args, interactive=False)
|
|
67
|
+
script = Path(args.script)
|
|
68
|
+
print(f"Watching {script} - suggestions appear each time you save. Ctrl+C to stop.")
|
|
69
|
+
last = None
|
|
70
|
+
try:
|
|
71
|
+
while True:
|
|
72
|
+
mtime = script.stat().st_mtime if script.exists() else None
|
|
73
|
+
if mtime and mtime != last:
|
|
74
|
+
last = mtime
|
|
75
|
+
code = script.read_text(encoding="utf-8")
|
|
76
|
+
try:
|
|
77
|
+
compile(code, str(script), "exec")
|
|
78
|
+
except SyntaxError as e:
|
|
79
|
+
print(f"[{time.strftime('%H:%M:%S')}] SyntaxError line {e.lineno}: {e.msg}")
|
|
80
|
+
continue
|
|
81
|
+
found = _static_check(s, script, args.data)
|
|
82
|
+
stamp = time.strftime("%H:%M:%S")
|
|
83
|
+
print(f"[{stamp}] {len(found)} new suggestion(s)")
|
|
84
|
+
for sug in found:
|
|
85
|
+
print(sug.render())
|
|
86
|
+
print()
|
|
87
|
+
time.sleep(args.interval)
|
|
88
|
+
except KeyboardInterrupt:
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_run(args) -> int:
|
|
93
|
+
s = _session(args)
|
|
94
|
+
script = Path(args.script)
|
|
95
|
+
s.ns.update({"__name__": "__main__", "__file__": str(script.resolve())})
|
|
96
|
+
sys.path.insert(0, str(script.parent.resolve()))
|
|
97
|
+
for d in args.data or []:
|
|
98
|
+
s.read_csv(d)
|
|
99
|
+
code = script.read_text(encoding="utf-8")
|
|
100
|
+
s.check(code, extra_frames=s.profiles_for_code(code, base_dir=script.parent))
|
|
101
|
+
result = s.run(code, check=False, filename=str(script))
|
|
102
|
+
if result.ok and result.changed and args.write_fixed:
|
|
103
|
+
out = script.with_suffix(".fixed.py")
|
|
104
|
+
out.write_text(result.final_code, encoding="utf-8")
|
|
105
|
+
print(f"Corrected copy written to {out} (your original is unchanged).")
|
|
106
|
+
return 0 if result.ok else 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cmd_repl(args) -> int:
|
|
110
|
+
return _repl(_session(args), args.data)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _repl(s: Session, data: str | None) -> int:
|
|
114
|
+
import numpy as np
|
|
115
|
+
import pandas as pd
|
|
116
|
+
|
|
117
|
+
s.ns.update(pd=pd, np=np)
|
|
118
|
+
ready = ["pd", "np"]
|
|
119
|
+
if data:
|
|
120
|
+
s.ns["df"] = s.read_csv(data)
|
|
121
|
+
ready.insert(0, "df")
|
|
122
|
+
print(f"learnstat REPL - AI: {s.ai_status}. Ready: {', '.join(ready)}. Type /help for commands, /ai to change the AI, Ctrl+Z/Ctrl+D to exit.")
|
|
123
|
+
buffer: list[str] = []
|
|
124
|
+
while True:
|
|
125
|
+
try:
|
|
126
|
+
line = input("... " if buffer else ">>> ")
|
|
127
|
+
except (EOFError, KeyboardInterrupt):
|
|
128
|
+
print()
|
|
129
|
+
return 0
|
|
130
|
+
if not buffer and is_command(line):
|
|
131
|
+
print(s.command(line))
|
|
132
|
+
continue
|
|
133
|
+
buffer.append(line)
|
|
134
|
+
src = "\n".join(buffer)
|
|
135
|
+
try:
|
|
136
|
+
complete = codeop.compile_command(src, symbol="single") is not None
|
|
137
|
+
except (SyntaxError, ValueError, OverflowError):
|
|
138
|
+
complete = True # let the runner diagnose it
|
|
139
|
+
if not complete:
|
|
140
|
+
continue
|
|
141
|
+
buffer = []
|
|
142
|
+
result = s.run(src)
|
|
143
|
+
if result.ok and result.value is not None:
|
|
144
|
+
print(repr(result.value))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _interactive() -> bool:
|
|
148
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cmd_config(args) -> int:
|
|
152
|
+
from . import config
|
|
153
|
+
from .setup import describe_profile
|
|
154
|
+
|
|
155
|
+
action = args.action
|
|
156
|
+
try:
|
|
157
|
+
if action == "path":
|
|
158
|
+
print(config.config_path())
|
|
159
|
+
elif action == "list":
|
|
160
|
+
saved, active = config.profiles(), config.active_name()
|
|
161
|
+
for name, prof in saved.items():
|
|
162
|
+
print(describe_profile(name, prof, name == active))
|
|
163
|
+
if not saved:
|
|
164
|
+
print("No saved AI accounts. Run: learnstat setup")
|
|
165
|
+
elif action == "show":
|
|
166
|
+
cfg = config.resolve()
|
|
167
|
+
stored = config.profiles().get(cfg.profile or "", {})
|
|
168
|
+
print(f"profile {cfg.profile or '(none)'} provider {cfg.provider}")
|
|
169
|
+
for key in config.KEYS:
|
|
170
|
+
value = getattr(cfg, key)
|
|
171
|
+
shown = config.mask(value) if key == "api_key" else value
|
|
172
|
+
env = config.ENV_VARS[key]
|
|
173
|
+
source = "env " + env if os.environ.get(env, "").strip() else ("saved" if key in stored else "default")
|
|
174
|
+
print(f"{key:9} {shown} [{source}]")
|
|
175
|
+
print(f"file {config.config_path()}")
|
|
176
|
+
elif action in ("use", "remove"):
|
|
177
|
+
if not args.key:
|
|
178
|
+
print(f"usage: learnstat config {action} <profile>")
|
|
179
|
+
return 2
|
|
180
|
+
path = config.set_active(args.key) if action == "use" else config.remove_profile(args.key)
|
|
181
|
+
print(f"{action} {args.key} -> {path}")
|
|
182
|
+
elif action in ("set", "unset"):
|
|
183
|
+
if not args.key or (action == "set" and args.value is None):
|
|
184
|
+
print("usage: learnstat config set <api_key|base_url|model> <value>")
|
|
185
|
+
print(" learnstat config unset <key>")
|
|
186
|
+
return 2
|
|
187
|
+
path = config.set_value(args.key, args.value) if action == "set" else config.unset_value(args.key)
|
|
188
|
+
print(f"{action} {args.key} -> {path}")
|
|
189
|
+
elif action == "test":
|
|
190
|
+
from .llm import LLMAssistant
|
|
191
|
+
|
|
192
|
+
a = LLMAssistant()
|
|
193
|
+
if not a.available():
|
|
194
|
+
print(f"AI not ready: {a.last_error}")
|
|
195
|
+
return 1
|
|
196
|
+
print(f"Testing {a.cfg.chat_url} with model {a.model} ...")
|
|
197
|
+
reply = a.test()
|
|
198
|
+
if reply is None:
|
|
199
|
+
print(f"X {a.last_error}")
|
|
200
|
+
return 1
|
|
201
|
+
print(f"OK - {a.cfg.provider_name} / {a.model} replied: {reply[:80]}")
|
|
202
|
+
except KeyError as e:
|
|
203
|
+
print(e.args[0])
|
|
204
|
+
return 2
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _ask_data() -> str | None:
|
|
209
|
+
try:
|
|
210
|
+
print()
|
|
211
|
+
path = input("Dataset to load (CSV/Excel path, Enter to skip): ").strip().strip('"')
|
|
212
|
+
except (EOFError, KeyboardInterrupt):
|
|
213
|
+
return None
|
|
214
|
+
if path and not Path(path).exists():
|
|
215
|
+
print(f" '{path}' was not found - starting without a dataset.")
|
|
216
|
+
return None
|
|
217
|
+
return path or None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _start(args, run_setup: bool) -> int:
|
|
221
|
+
"""The guided start: (optional) AI setup, a quick AI check, then the interactive session."""
|
|
222
|
+
from . import config
|
|
223
|
+
from .llm import LLMAssistant
|
|
224
|
+
from .setup import IO, run_wizard
|
|
225
|
+
|
|
226
|
+
if not _interactive():
|
|
227
|
+
print("learnstat start needs an interactive terminal. Try: learnstat --help")
|
|
228
|
+
return 2
|
|
229
|
+
if not args.no_ai:
|
|
230
|
+
cfg_ready = config.resolve().is_configured
|
|
231
|
+
if run_setup:
|
|
232
|
+
run_wizard()
|
|
233
|
+
elif not cfg_ready and not config.skip_setup():
|
|
234
|
+
print()
|
|
235
|
+
print("No AI assistant is set up yet (learnstat also works offline with built-in rules).")
|
|
236
|
+
try:
|
|
237
|
+
answer = input("Set one up now? [Y]es / [n]ot now / [never]: ").strip().lower()
|
|
238
|
+
except (EOFError, KeyboardInterrupt):
|
|
239
|
+
answer = "n"
|
|
240
|
+
if answer in ("", "y", "yes"):
|
|
241
|
+
run_wizard()
|
|
242
|
+
elif answer == "never":
|
|
243
|
+
config.set_skip_setup(True)
|
|
244
|
+
a = LLMAssistant()
|
|
245
|
+
if a.available():
|
|
246
|
+
print()
|
|
247
|
+
print(f"Checking AI: {a.cfg.provider_name} / {a.model} ...", end=" ", flush=True)
|
|
248
|
+
if a.test(timeout=15) is not None:
|
|
249
|
+
print("OK")
|
|
250
|
+
else:
|
|
251
|
+
print()
|
|
252
|
+
print(f"X {a.last_error}")
|
|
253
|
+
print(" Continuing with offline rules. Type /ai in the session to fix it.")
|
|
254
|
+
s = _session(args)
|
|
255
|
+
return _repl(s, args.data or _ask_data())
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def cmd_setup(args) -> int:
|
|
259
|
+
if not _interactive():
|
|
260
|
+
print("learnstat setup is interactive. In scripts use: learnstat config set api_key|base_url|model <value>")
|
|
261
|
+
return 2
|
|
262
|
+
from .setup import run_wizard
|
|
263
|
+
|
|
264
|
+
run_wizard()
|
|
265
|
+
from . import config
|
|
266
|
+
if config.resolve().is_configured:
|
|
267
|
+
try:
|
|
268
|
+
print()
|
|
269
|
+
go = input("Start learnstat now? [Y/n] ").strip().lower() in ("", "y", "yes")
|
|
270
|
+
except (EOFError, KeyboardInterrupt):
|
|
271
|
+
go = False
|
|
272
|
+
if go:
|
|
273
|
+
return _start(args, run_setup=False)
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def cmd_start(args) -> int:
|
|
278
|
+
return _start(args, run_setup=False)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def main(argv: list[str] | None = None) -> int:
|
|
282
|
+
p = argparse.ArgumentParser(prog="learnstat", description="AI-assisted learning companion for statistics in Python.")
|
|
283
|
+
p.add_argument("--memory-dir", default=os.environ.get("LEARNSTAT_MEMORY", ".learnstat"))
|
|
284
|
+
p.add_argument("--no-ai", action="store_true", help="never call the AI; use offline rules only")
|
|
285
|
+
p.add_argument("--data", help="dataset to load when starting the interactive session")
|
|
286
|
+
sub = p.add_subparsers(dest="cmd")
|
|
287
|
+
|
|
288
|
+
sp = sub.add_parser("setup", help="guided AI setup: choose company, enter key, pick model, check, then start")
|
|
289
|
+
sp.add_argument("--data")
|
|
290
|
+
sp.set_defaults(fn=cmd_setup)
|
|
291
|
+
|
|
292
|
+
sp = sub.add_parser("start", help="start the interactive session (also what plain `learnstat` does)")
|
|
293
|
+
sp.add_argument("--data")
|
|
294
|
+
sp.set_defaults(fn=cmd_start)
|
|
295
|
+
|
|
296
|
+
sp = sub.add_parser("config", help="scripting access to AI settings: list|use|remove|set|unset|show|test|path")
|
|
297
|
+
sp.add_argument("action", choices=["list", "use", "remove", "set", "unset", "show", "test", "path"])
|
|
298
|
+
sp.add_argument("key", nargs="?", help="profile name, or api_key / base_url / model")
|
|
299
|
+
sp.add_argument("value", nargs="?")
|
|
300
|
+
sp.set_defaults(fn=cmd_config)
|
|
301
|
+
|
|
302
|
+
sp = sub.add_parser("profile", help="profile a dataset and write its Markdown memory")
|
|
303
|
+
sp.add_argument("data")
|
|
304
|
+
sp.add_argument("--name")
|
|
305
|
+
sp.set_defaults(fn=cmd_profile)
|
|
306
|
+
|
|
307
|
+
for name, fn, help_ in [("check", cmd_check, "suggest improvements for a script without running it"),
|
|
308
|
+
("watch", cmd_watch, "re-check a script every time it is saved")]:
|
|
309
|
+
sp = sub.add_parser(name, help=help_)
|
|
310
|
+
sp.add_argument("script")
|
|
311
|
+
sp.add_argument("--data", action="append", help="dataset file(s) the script analyses")
|
|
312
|
+
sp.add_argument("--ai-review", action="store_true", help="also ask the AI to review the code")
|
|
313
|
+
if name == "watch":
|
|
314
|
+
sp.add_argument("--interval", type=float, default=1.0)
|
|
315
|
+
sp.set_defaults(fn=fn)
|
|
316
|
+
|
|
317
|
+
sp = sub.add_parser("run", help="run a script with suggestions and guarded error recovery")
|
|
318
|
+
sp.add_argument("script")
|
|
319
|
+
sp.add_argument("--data", action="append")
|
|
320
|
+
sp.add_argument("--max-attempts", type=int, default=3)
|
|
321
|
+
sp.add_argument("--no-fix", action="store_true", help="diagnose errors but never correct them")
|
|
322
|
+
sp.add_argument("--yes", action="store_true", help="approve side-effecting code and AI corrections without asking")
|
|
323
|
+
sp.add_argument("--write-fixed", action="store_true", help="write the corrected script to <name>.fixed.py")
|
|
324
|
+
sp.set_defaults(fn=cmd_run)
|
|
325
|
+
|
|
326
|
+
sp = sub.add_parser("repl", help="interactive session with suggestions, recovery and /commands")
|
|
327
|
+
sp.add_argument("--data")
|
|
328
|
+
sp.add_argument("--max-attempts", type=int, default=3)
|
|
329
|
+
sp.set_defaults(fn=cmd_repl)
|
|
330
|
+
|
|
331
|
+
args = p.parse_args(argv)
|
|
332
|
+
if args.cmd is None:
|
|
333
|
+
return cmd_start(args)
|
|
334
|
+
return args.fn(args)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
if __name__ == "__main__":
|
|
338
|
+
sys.exit(main())
|
learnstat/commands.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Slash commands that operate on the current analytical context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Callable
|
|
6
|
+
|
|
7
|
+
from .knowledge import Concept, describe_concept, find_topic
|
|
8
|
+
from .suggestions import Suggestion
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .session import Session
|
|
12
|
+
|
|
13
|
+
HELP = """learnstat commands
|
|
14
|
+
/describe [id] explain the reasoning behind the latest (or given) suggestion or error
|
|
15
|
+
/alternatives [id] other ways to reach the same analytical goal, with trade-offs
|
|
16
|
+
/fix show the last error diagnosis; retry the failed statement (AI-assisted if available)
|
|
17
|
+
/learn [topic] a short lesson on the concept behind the latest suggestion (or a topic)
|
|
18
|
+
/memory [show|note <text>|decide <text>|refresh]
|
|
19
|
+
view or update the dataset's persistent Markdown profile
|
|
20
|
+
/suggestions list suggestions from this session
|
|
21
|
+
/accept [id] accept a suggestion (records the decision; offers the code - you paste/run it)
|
|
22
|
+
/reject [id] [why] keep your approach (the decision is recorded so it is not suggested again)
|
|
23
|
+
/review ask the AI to review the last code for issues the rules may miss
|
|
24
|
+
/ask <question> ask a question about your data or code (AI)
|
|
25
|
+
/ai [list|use <name>] choose the AI company, key and model (guided), or switch saved accounts
|
|
26
|
+
/status show AI status and loaded datasets
|
|
27
|
+
/help this help"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _split(text: str) -> tuple[str, str]:
|
|
31
|
+
text = text.strip()
|
|
32
|
+
if not text.startswith("/"):
|
|
33
|
+
return "", text
|
|
34
|
+
head, _, rest = text[1:].partition(" ")
|
|
35
|
+
return head.lower(), rest.strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_id(rest: str) -> tuple[int | None, str]:
|
|
39
|
+
parts = rest.split(maxsplit=1)
|
|
40
|
+
if parts and parts[0].lstrip("#").isdigit():
|
|
41
|
+
return int(parts[0].lstrip("#")), parts[1] if len(parts) > 1 else ""
|
|
42
|
+
return None, rest
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _concept_text(c: Concept, sections: tuple[str, ...]) -> str:
|
|
46
|
+
out = [c.title, "=" * len(c.title)]
|
|
47
|
+
if "concept" in sections:
|
|
48
|
+
out += ["", "The statistical idea:", " " + c.concept]
|
|
49
|
+
if "purpose" in sections:
|
|
50
|
+
out += ["", "What the suggested function does:", " " + c.purpose]
|
|
51
|
+
if "when" in sections:
|
|
52
|
+
out += ["", "When it is appropriate, and when it is not:", " " + c.appropriate_when]
|
|
53
|
+
if "alternatives" in sections and c.alternatives:
|
|
54
|
+
out += ["", "Alternatives:"] + [f" - {a}" for a in c.alternatives]
|
|
55
|
+
if "lesson" in sections and c.lesson:
|
|
56
|
+
out += ["", c.lesson]
|
|
57
|
+
if c.docs:
|
|
58
|
+
out += ["", "Docs: " + ", ".join(c.docs)]
|
|
59
|
+
return "\n".join(out)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _with_ai(session: "Session", kind: str, s: Suggestion, offline: str) -> str:
|
|
63
|
+
if not session.assistant:
|
|
64
|
+
return offline
|
|
65
|
+
text = session.assistant.explain(kind, s.render(), offline, session.profile_markdown(), s.source_code or session.last_code)
|
|
66
|
+
if text:
|
|
67
|
+
return text
|
|
68
|
+
return offline + f"\n\n(AI explanation unavailable: {session.assistant.last_error}; showing the offline explanation.)"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _target(session: "Session", rest: str) -> tuple[Suggestion | None, str]:
|
|
72
|
+
sid, rest = _parse_id(rest)
|
|
73
|
+
s = session.history.get(sid)
|
|
74
|
+
if sid is not None and s is None:
|
|
75
|
+
return None, f"No suggestion #{sid}. Use /suggestions to list them."
|
|
76
|
+
return s, rest
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_describe(session: "Session", rest: str) -> str:
|
|
83
|
+
s, err = _target(session, rest)
|
|
84
|
+
result = session.last_result
|
|
85
|
+
if s is None and not rest and result and result.attempts:
|
|
86
|
+
return _describe_error(session)
|
|
87
|
+
if s is None:
|
|
88
|
+
return err or "Nothing to describe yet - write some code first, or try /learn <topic>."
|
|
89
|
+
facts = (
|
|
90
|
+
f"Suggestion #{s.id}: {s.title}\n\n"
|
|
91
|
+
f"In your code: {s.source_code or '(see above)'}\n"
|
|
92
|
+
f"For your data: {s.problem}\n"
|
|
93
|
+
f"Suggested: {s.alternative}\n"
|
|
94
|
+
f"Reason: {s.rationale}\n"
|
|
95
|
+
)
|
|
96
|
+
c = describe_concept(s.concept)
|
|
97
|
+
offline = facts + ("\n" + _concept_text(c, ("concept", "purpose", "when")) if c else "")
|
|
98
|
+
return _with_ai(session, "describe", s, offline)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _describe_error(session: "Session") -> str:
|
|
102
|
+
r = session.last_result
|
|
103
|
+
a = r.attempts[-1]
|
|
104
|
+
d = a.diagnosis
|
|
105
|
+
out = [
|
|
106
|
+
f"Last error: {d.exc_type} ({d.label})",
|
|
107
|
+
f" Message: {d.message}",
|
|
108
|
+
f" Statement: {d.statement.strip()}",
|
|
109
|
+
f" Cause: {d.explanation}",
|
|
110
|
+
]
|
|
111
|
+
if a.fix:
|
|
112
|
+
out.append(f" Correction: {a.fix.description} ({'applied' if a.applied else 'not applied'})")
|
|
113
|
+
out.append(f" Next step: {d.next_step}")
|
|
114
|
+
out.append(
|
|
115
|
+
"\nlearnstat separates errors into syntax, name, attribute, argument, column, data-quality, "
|
|
116
|
+
"statistical-method and environment errors. Only mechanical mistakes are corrected automatically; "
|
|
117
|
+
"data-quality and statistical problems are explained so that you make the decision."
|
|
118
|
+
)
|
|
119
|
+
return "\n".join(out)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_alternatives(session: "Session", rest: str) -> str:
|
|
123
|
+
s, err = _target(session, rest)
|
|
124
|
+
if s is None:
|
|
125
|
+
return err or "No suggestion to give alternatives for yet."
|
|
126
|
+
c = describe_concept(s.concept)
|
|
127
|
+
offline = (
|
|
128
|
+
f"Alternatives for suggestion #{s.id} ({s.title}):\n"
|
|
129
|
+
f" - {s.alternative}\n" + ("\n".join(f" - {a}" for a in c.alternatives) if c else "")
|
|
130
|
+
)
|
|
131
|
+
return _with_ai(session, "alternatives", s, offline)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_learn(session: "Session", rest: str) -> str:
|
|
135
|
+
if rest:
|
|
136
|
+
c = find_topic(rest)
|
|
137
|
+
if session.assistant:
|
|
138
|
+
text = session.assistant.ask(
|
|
139
|
+
f"Teach me about: {rest}. Keep it under 300 words, use my dataset's columns in the examples and end "
|
|
140
|
+
"with one exercise.", session.profile_markdown(), session.last_code)
|
|
141
|
+
if text:
|
|
142
|
+
return text
|
|
143
|
+
return _concept_text(c, ("concept", "purpose", "when", "alternatives", "lesson")) if c else (
|
|
144
|
+
f"No offline lesson matches '{rest}'. Topics: pie charts, skewed data, t-test, correlation, "
|
|
145
|
+
"missing values, categorical plots, dropna, column names, text numbers, histograms."
|
|
146
|
+
)
|
|
147
|
+
s = session.history.get()
|
|
148
|
+
if s is None:
|
|
149
|
+
return "Nothing to learn from yet. Try /learn <topic>, e.g. /learn correlation."
|
|
150
|
+
c = describe_concept(s.concept)
|
|
151
|
+
offline = _concept_text(c, ("concept", "purpose", "when", "alternatives", "lesson")) if c else s.render()
|
|
152
|
+
return _with_ai(session, "learn", s, offline)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def cmd_fix(session: "Session", rest: str) -> str:
|
|
156
|
+
r = session.last_result
|
|
157
|
+
if r is None or (r.ok and not r.attempts):
|
|
158
|
+
return "No failed code in this session."
|
|
159
|
+
if r.ok:
|
|
160
|
+
return "The last run was recovered:\n\n" + r.report()
|
|
161
|
+
report = r.report()
|
|
162
|
+
if r.stopped_at_line is None:
|
|
163
|
+
return report
|
|
164
|
+
if session.confirm is None:
|
|
165
|
+
return report + "\n\n(/fix can retry the failed statement when running interactively.)"
|
|
166
|
+
if not session.confirm(f"Retry from line {r.stopped_at_line}" + (" with AI assistance" if session.assistant else "") + "?"):
|
|
167
|
+
return report
|
|
168
|
+
retry = session.executor().run(r.final_code, start_line=r.stopped_at_line)
|
|
169
|
+
retry.original_code = r.original_code
|
|
170
|
+
session._after_run(r.final_code, retry)
|
|
171
|
+
return "Retry " + ("succeeded." if retry.ok else "did not succeed - see the next step above.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cmd_memory(session: "Session", rest: str) -> str:
|
|
175
|
+
mem = session.dataset_memory()
|
|
176
|
+
if mem is None:
|
|
177
|
+
return "No dataset in memory. Load one with learnstat.read_csv(...) (or session.track(df))."
|
|
178
|
+
sub, _, arg = rest.partition(" ")
|
|
179
|
+
path = session.memory.md_path(mem.profile.name)
|
|
180
|
+
if sub in ("", "summary"):
|
|
181
|
+
p = mem.profile
|
|
182
|
+
return (
|
|
183
|
+
f"Dataset '{p.name}' - {p.n_rows} rows x {p.n_cols} columns, profile {p.fingerprint}\n"
|
|
184
|
+
f"Issues: {len(p.issues)}, decisions recorded: {len(mem.decisions)}, notes: {len(mem.notes)}\n"
|
|
185
|
+
f"File: {path}\nUse '/memory show' for the full profile."
|
|
186
|
+
)
|
|
187
|
+
if sub == "show":
|
|
188
|
+
return path.read_text(encoding="utf-8") if path.exists() else "(memory file missing - try /memory refresh)"
|
|
189
|
+
if sub == "note" and arg:
|
|
190
|
+
session.memory.add_note(mem, arg)
|
|
191
|
+
return f"Note saved to {path}."
|
|
192
|
+
if sub == "decide" and arg:
|
|
193
|
+
session.memory.record_decision(mem, arg)
|
|
194
|
+
return f"Decision recorded in {path}."
|
|
195
|
+
if sub == "refresh":
|
|
196
|
+
for var, obj in session.ns.items():
|
|
197
|
+
if session._frame_ids.get(id(obj)) == mem.profile.name:
|
|
198
|
+
session.track(obj, name=mem.profile.name, source=mem.profile.source, force=True)
|
|
199
|
+
return f"Re-profiled '{mem.profile.name}'."
|
|
200
|
+
return "The tracked DataFrame is not in the current namespace; reload it to refresh."
|
|
201
|
+
return "Usage: /memory [show | note <text> | decide <text> | refresh]"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_suggestions(session: "Session", rest: str) -> str:
|
|
205
|
+
if not session.history.items:
|
|
206
|
+
return "No suggestions yet."
|
|
207
|
+
return "\n".join(
|
|
208
|
+
f"#{s.id} [{s.status}] line {s.line or '-'}: {s.title}" for s in session.history.items
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cmd_accept(session: "Session", rest: str) -> str:
|
|
213
|
+
sid, _ = _parse_id(rest)
|
|
214
|
+
s = session.history.get(sid) if sid else session.history.latest_pending()
|
|
215
|
+
if s is None:
|
|
216
|
+
return "No pending suggestion to accept."
|
|
217
|
+
s.status = "accepted"
|
|
218
|
+
mem = session.dataset_for(s)
|
|
219
|
+
if mem:
|
|
220
|
+
session.memory.record_decision(mem, f"Accepted suggestion '{s.title}': {s.alternative}")
|
|
221
|
+
if s.code and session.on_accept:
|
|
222
|
+
session.on_accept(s.code)
|
|
223
|
+
return f"Accepted #{s.id}. The suggested code was placed in a new cell for you to edit and run."
|
|
224
|
+
return f"Accepted #{s.id}. Your code was not changed - here is the suggested version to adapt:\n\n{s.code or s.alternative}"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def cmd_reject(session: "Session", rest: str) -> str:
|
|
228
|
+
sid, reason = _parse_id(rest)
|
|
229
|
+
s = session.history.get(sid) if sid else session.history.latest_pending()
|
|
230
|
+
if s is None:
|
|
231
|
+
return "No pending suggestion to reject."
|
|
232
|
+
s.status = "rejected"
|
|
233
|
+
mem = session.dataset_for(s)
|
|
234
|
+
if mem:
|
|
235
|
+
why = f" Reason: {reason}" if reason else ""
|
|
236
|
+
session.memory.record_decision(mem, f"Kept original approach despite '{s.title}'.{why}")
|
|
237
|
+
return f"Rejected #{s.id}; your approach stays as written."
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def cmd_review(session: "Session", rest: str) -> str:
|
|
241
|
+
if not session.assistant:
|
|
242
|
+
return f"AI review is {session.ai_status}."
|
|
243
|
+
if not session.last_code:
|
|
244
|
+
return "No code to review yet."
|
|
245
|
+
found = session._ai_review(session.last_code, [s for s in session.history.items])
|
|
246
|
+
session.history.extend(found)
|
|
247
|
+
return "\n".join(s.render() for s in found) or "No additional issues found."
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def cmd_ask(session: "Session", rest: str) -> str:
|
|
251
|
+
if not rest:
|
|
252
|
+
return "Usage: /ask <question>"
|
|
253
|
+
if not session.assistant:
|
|
254
|
+
return f"/ask needs AI, which is {session.ai_status}. Try /learn {rest}"
|
|
255
|
+
return session.assistant.ask(rest, session.profile_markdown(), session.last_code) or (
|
|
256
|
+
f"AI unavailable: {session.assistant.last_error}"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cmd_ai(session: "Session", rest: str) -> str:
|
|
261
|
+
from . import config
|
|
262
|
+
from .setup import describe_profile, run_wizard, use_saved, IO
|
|
263
|
+
|
|
264
|
+
sub, _, arg = rest.partition(" ")
|
|
265
|
+
if sub == "list":
|
|
266
|
+
saved, active = config.profiles(), config.active_name()
|
|
267
|
+
return "\n".join(describe_profile(n, p, n == active) for n, p in saved.items()) or "No saved AI accounts. Run /ai to add one."
|
|
268
|
+
if sub == "use":
|
|
269
|
+
if arg not in config.profiles():
|
|
270
|
+
return f"No saved account '{arg}'. Use /ai list."
|
|
271
|
+
if not use_saved(IO(), arg):
|
|
272
|
+
return f"Kept the current AI: {session.ai_status}"
|
|
273
|
+
elif sub in ("", "setup"):
|
|
274
|
+
run_wizard()
|
|
275
|
+
else:
|
|
276
|
+
return "Usage: /ai guided setup: company -> key -> model -> check\n /ai list saved accounts\n /ai use <name> switch account"
|
|
277
|
+
session.connect_ai()
|
|
278
|
+
return f"AI: {session.ai_status}"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def cmd_status(session: "Session", rest: str) -> str:
|
|
282
|
+
names = ", ".join(session.datasets) or "none"
|
|
283
|
+
return f"AI: {session.ai_status}\nDatasets: {names}\nMemory directory: {session.memory.directory}"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
COMMANDS: dict[str, Callable[["Session", str], str]] = {
|
|
287
|
+
"describe": cmd_describe,
|
|
288
|
+
"alternatives": cmd_alternatives,
|
|
289
|
+
"alt": cmd_alternatives,
|
|
290
|
+
"fix": cmd_fix,
|
|
291
|
+
"learn": cmd_learn,
|
|
292
|
+
"memory": cmd_memory,
|
|
293
|
+
"suggestions": cmd_suggestions,
|
|
294
|
+
"accept": cmd_accept,
|
|
295
|
+
"reject": cmd_reject,
|
|
296
|
+
"review": cmd_review,
|
|
297
|
+
"ask": cmd_ask,
|
|
298
|
+
"status": cmd_status,
|
|
299
|
+
"ai": cmd_ai,
|
|
300
|
+
"help": lambda s, r: HELP,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def dispatch(session: "Session", text: str) -> str:
|
|
305
|
+
name, rest = _split(text)
|
|
306
|
+
fn = COMMANDS.get(name)
|
|
307
|
+
if fn is None:
|
|
308
|
+
return f"Unknown command '/{name}'.\n\n{HELP}"
|
|
309
|
+
return fn(session, rest)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def is_command(text: str) -> bool:
|
|
313
|
+
name, _ = _split(text)
|
|
314
|
+
return bool(name) and name in COMMANDS
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
__all__ = ["dispatch", "is_command", "HELP"]
|