urok 0.1.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.
- urok/__init__.py +3 -0
- urok/__main__.py +4 -0
- urok/cli.py +268 -0
- urok/llm.py +184 -0
- urok/store.py +82 -0
- urok-0.1.0.dist-info/METADATA +151 -0
- urok-0.1.0.dist-info/RECORD +9 -0
- urok-0.1.0.dist-info/WHEEL +4 -0
- urok-0.1.0.dist-info/entry_points.txt +2 -0
urok/__init__.py
ADDED
urok/__main__.py
ADDED
urok/cli.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""REPL: диалог в терминале. Сообщение — одна строка, команды начинаются со /."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from urok import llm, store
|
|
9
|
+
|
|
10
|
+
BANNER = "urok {version} — помощник для учёбы. /help — команды, /exit — выход."
|
|
11
|
+
|
|
12
|
+
HELP = """
|
|
13
|
+
/help эта справка
|
|
14
|
+
/exit выйти (или Ctrl+D)
|
|
15
|
+
/clear забыть текущий диалог
|
|
16
|
+
/model текущая модель
|
|
17
|
+
/model имя переключить модель прямо сейчас
|
|
18
|
+
/models список моделей на сервере (свободные помечены -free)
|
|
19
|
+
/base текущий адрес сервера
|
|
20
|
+
/base адрес сменить сервер
|
|
21
|
+
/system показать или сменить системный промпт
|
|
22
|
+
/wipe удалить всю сохранённую историю с диска
|
|
23
|
+
/sessions список сохранённых сессий
|
|
24
|
+
/copy скопировать последний ответ в буфер обмена
|
|
25
|
+
|
|
26
|
+
По умолчанию история никуда не пишется: закрыл консоль — на диске ничего.
|
|
27
|
+
Запомнить диалог: запусти с --keep-history.
|
|
28
|
+
""".strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser():
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="urok",
|
|
34
|
+
description="Консольный помощник для учёбы.",
|
|
35
|
+
epilog=(
|
|
36
|
+
"По умолчанию: OpenCode Zen, модель space-bunny-free, ключ public. "
|
|
37
|
+
"Ничего настраивать не нужно."
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument("--base", help="адрес OpenAI-совместимого API")
|
|
41
|
+
parser.add_argument("--key", help="API-ключ (или переменная UROK_API_KEY)")
|
|
42
|
+
parser.add_argument("--model", help="имя модели, например muse-spark-1.3-free")
|
|
43
|
+
parser.add_argument("--grade", default="", help="класс, например 9")
|
|
44
|
+
parser.add_argument("--subject", default="", help="предмет, например физика")
|
|
45
|
+
parser.add_argument("--system", help="свой системный промпт вместо умолчания")
|
|
46
|
+
parser.add_argument("--session", help="загрузить сессию из файла (нужен --keep-history)")
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--keep-history",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help="сохранять диалог в ~/.urok. По умолчанию история не пишется на диск",
|
|
51
|
+
)
|
|
52
|
+
return parser
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv=None):
|
|
56
|
+
args = build_parser().parse_args(argv)
|
|
57
|
+
base, model, key = llm.resolve(args.base, args.model, args.key)
|
|
58
|
+
cfg = {"base": base, "model": model, "key": key}
|
|
59
|
+
system_text = args.system or store.system_prompt(args.grade, args.subject)
|
|
60
|
+
|
|
61
|
+
session = None
|
|
62
|
+
if args.keep_history:
|
|
63
|
+
session = _resolve_session(args.session) if args.session else None
|
|
64
|
+
if session is None:
|
|
65
|
+
session = store.new_session()
|
|
66
|
+
|
|
67
|
+
messages = [{"role": "system", "content": system_text}]
|
|
68
|
+
messages.extend(_turns(session))
|
|
69
|
+
|
|
70
|
+
print(BANNER.format(version=_version()))
|
|
71
|
+
print(f"модель: {model} сервер: {base}")
|
|
72
|
+
print(f"история: {session}" if session else "история: не сохраняется (--keep-history чтобы включить)")
|
|
73
|
+
_preflight(cfg)
|
|
74
|
+
|
|
75
|
+
while True:
|
|
76
|
+
try:
|
|
77
|
+
line = input("\n> ").strip()
|
|
78
|
+
except EOFError:
|
|
79
|
+
print()
|
|
80
|
+
_farewell(session)
|
|
81
|
+
return 0
|
|
82
|
+
except KeyboardInterrupt:
|
|
83
|
+
print(" (выход)")
|
|
84
|
+
_farewell(session)
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
if not line:
|
|
88
|
+
continue
|
|
89
|
+
if line.startswith("/"):
|
|
90
|
+
if _command(line, cfg, messages, session, system_text) == "exit":
|
|
91
|
+
_farewell(session)
|
|
92
|
+
return 0
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
messages.append({"role": "user", "content": line})
|
|
96
|
+
reply = _ask(messages, cfg)
|
|
97
|
+
if reply is None:
|
|
98
|
+
messages.pop() # запрос не ушёл — не оставляем битое состояние
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
messages.append({"role": "assistant", "content": reply})
|
|
102
|
+
if session:
|
|
103
|
+
store.append(session, "user", line)
|
|
104
|
+
store.append(session, "assistant", reply)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _farewell(session):
|
|
108
|
+
"""Подтверждает фактическое состояние диска при выходе."""
|
|
109
|
+
if session:
|
|
110
|
+
print(f"диалог сохранён: {session}")
|
|
111
|
+
return
|
|
112
|
+
left = store.folder()
|
|
113
|
+
if left.exists():
|
|
114
|
+
print(f"внимание: на диске остался {left} — это папка прошлых сессий, /wipe удалит")
|
|
115
|
+
else:
|
|
116
|
+
print("на диск ничего не записано, следов нет")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _ask(messages, cfg):
|
|
120
|
+
"""Стримит ответ. None означает отмену пользователем (Ctrl+C)."""
|
|
121
|
+
print(" ...", end=" ", flush=True)
|
|
122
|
+
try:
|
|
123
|
+
return llm.chat(
|
|
124
|
+
messages,
|
|
125
|
+
base=cfg["base"],
|
|
126
|
+
model=cfg["model"],
|
|
127
|
+
key=cfg["key"],
|
|
128
|
+
on_token=_print_token,
|
|
129
|
+
)
|
|
130
|
+
except KeyboardInterrupt:
|
|
131
|
+
print("\n (прервано)")
|
|
132
|
+
return None
|
|
133
|
+
except llm.LLMError as exc:
|
|
134
|
+
print(f"\n\nОшибка: {exc}")
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _print_token(text):
|
|
139
|
+
sys.stdout.write(text)
|
|
140
|
+
sys.stdout.flush()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _command(line, cfg, messages, session, system_text):
|
|
144
|
+
name, _, rest = line[1:].partition(" ")
|
|
145
|
+
name = name.lower()
|
|
146
|
+
rest = rest.strip()
|
|
147
|
+
|
|
148
|
+
if name in ("exit", "quit", "q"):
|
|
149
|
+
return "exit"
|
|
150
|
+
|
|
151
|
+
if name == "help":
|
|
152
|
+
print(HELP)
|
|
153
|
+
elif name == "clear":
|
|
154
|
+
messages[:] = [{"role": "system", "content": messages[0]["content"]}]
|
|
155
|
+
print("диалог очищен")
|
|
156
|
+
elif name == "model":
|
|
157
|
+
if rest:
|
|
158
|
+
cfg["model"] = rest
|
|
159
|
+
print("модель:", rest)
|
|
160
|
+
else:
|
|
161
|
+
print(cfg["model"])
|
|
162
|
+
elif name == "models":
|
|
163
|
+
_print_models(cfg)
|
|
164
|
+
elif name == "base":
|
|
165
|
+
if rest:
|
|
166
|
+
cfg["base"] = rest.rstrip("/")
|
|
167
|
+
print("сервер:", cfg["base"])
|
|
168
|
+
else:
|
|
169
|
+
print(cfg["base"])
|
|
170
|
+
elif name == "system":
|
|
171
|
+
messages[0]["content"] = rest or system_text
|
|
172
|
+
print("системный промпт:", messages[0]["content"][:120])
|
|
173
|
+
elif name == "wipe":
|
|
174
|
+
count, removed = store.wipe()
|
|
175
|
+
print(f"удалено сессий: {count}" if removed else "удалять нечего")
|
|
176
|
+
elif name == "load":
|
|
177
|
+
if not _history_on(store.folder()):
|
|
178
|
+
print("история не сохранялась. Запусти с --keep-history")
|
|
179
|
+
return "keep"
|
|
180
|
+
rows = store.load(_named(rest) or _latest_session())
|
|
181
|
+
if rows:
|
|
182
|
+
messages[:] = [messages[0]] + rows
|
|
183
|
+
print(f"загружено сообщений: {len(rows)}")
|
|
184
|
+
else:
|
|
185
|
+
print("нечего загружать")
|
|
186
|
+
elif name == "sessions":
|
|
187
|
+
items = store.list_sessions()
|
|
188
|
+
if not items:
|
|
189
|
+
print("пусто" if not _history_on(store.folder()) else "сессий нет")
|
|
190
|
+
for item in items:
|
|
191
|
+
print(" ", item.name)
|
|
192
|
+
elif name == "copy":
|
|
193
|
+
_copy_last(messages)
|
|
194
|
+
else:
|
|
195
|
+
print("неизвестная команда. /help — список")
|
|
196
|
+
return "keep"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _preflight(cfg):
|
|
200
|
+
"""Проверка связи до первого вопроса, чтобы не гадать потом."""
|
|
201
|
+
try:
|
|
202
|
+
llm.list_models(cfg["base"], cfg["key"])
|
|
203
|
+
print("сервер отвечает")
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
print(f"сервер не отвечает: {exc}")
|
|
206
|
+
print("проверить: /models — после настройки. Диалог всё равно доступен.")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _print_models(cfg):
|
|
210
|
+
try:
|
|
211
|
+
names = llm.list_models(cfg["base"], cfg["key"])
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
print(f"не получилось: {exc}")
|
|
214
|
+
return
|
|
215
|
+
print("\n".join(" " + n for n in names) or " сервер не вернул моделей")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _copy_last(messages):
|
|
219
|
+
if sys.platform == "win32":
|
|
220
|
+
import subprocess
|
|
221
|
+
|
|
222
|
+
last = messages[-1]["content"] if len(messages) > 1 else ""
|
|
223
|
+
if not last:
|
|
224
|
+
print("нечего копировать")
|
|
225
|
+
return
|
|
226
|
+
proc = subprocess.run(["clip"], input=last.encode("utf-16-le"), capture_output=True)
|
|
227
|
+
print("скопировано" if proc.returncode == 0 else "clip не сработал")
|
|
228
|
+
else:
|
|
229
|
+
print("буфер обмена доступен на Windows. Покажи ответ командой /save.")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _resolve_session(arg):
|
|
233
|
+
if arg.isdigit():
|
|
234
|
+
sessions = store.list_sessions()
|
|
235
|
+
index = int(arg) - 1
|
|
236
|
+
return sessions[index] if 0 <= index < len(sessions) else None
|
|
237
|
+
candidate = store.folder() / arg
|
|
238
|
+
return candidate if candidate.exists() else None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _named(name):
|
|
242
|
+
return store.folder() / name if name else None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _latest_session():
|
|
246
|
+
sessions = store.list_sessions(1)
|
|
247
|
+
return sessions[0] if sessions else store.folder() / "нет.json"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _history_on(folder_path):
|
|
251
|
+
return any(folder_path.glob("session-*.json")) if folder_path.is_dir() else False
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _turns(session):
|
|
255
|
+
if not session:
|
|
256
|
+
return []
|
|
257
|
+
rows = store.load(session)
|
|
258
|
+
return [r for r in rows if r.get("role") in ("user", "assistant")]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _version():
|
|
262
|
+
from urok import __version__
|
|
263
|
+
|
|
264
|
+
return __version__
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
raise SystemExit(main())
|
urok/llm.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Клиент OpenAI-совместимого API. По умолчанию — OpenCode Zen, модель
|
|
2
|
+
space-bunny-free, ключ `public`. Ничего настраивать не нужно: работает сразу.
|
|
3
|
+
|
|
4
|
+
Любой другой адрес тоже подходит: Groq, OpenAI, LM Studio, Ollama, llama.cpp.
|
|
5
|
+
|
|
6
|
+
Только стандартная библиотека. Стриминг через SSE, поэтому ответ печатается
|
|
7
|
+
по мере генерации, а не паузу после запроса.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
from urok import __version__
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE = "https://opencode.ai/zen/v1"
|
|
20
|
+
DEFAULT_MODEL = "space-bunny-free"
|
|
21
|
+
DEFAULT_KEY = "public"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LLMError(RuntimeError):
|
|
25
|
+
"""Ошибка сети или сервера, пригодная для показа пользователю."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve(base=None, model=None, key=None):
|
|
29
|
+
"""Приоритет: аргумент > переменная окружения > значение по умолчанию."""
|
|
30
|
+
env_base = os.environ.get("UROK_BASE_URL", "").strip()
|
|
31
|
+
env_model = os.environ.get("UROK_MODEL", "").strip()
|
|
32
|
+
env_key = os.environ.get("UROK_API_KEY", "").strip()
|
|
33
|
+
|
|
34
|
+
base = (base or env_base or DEFAULT_BASE).rstrip("/")
|
|
35
|
+
model = model or env_model or DEFAULT_MODEL
|
|
36
|
+
key = key or env_key or DEFAULT_KEY
|
|
37
|
+
return base, model, key
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def list_models(base, key=DEFAULT_KEY):
|
|
41
|
+
"""Список моделей на сервере. Zen отдаёт бесплатные с суффиксом -free."""
|
|
42
|
+
request = urllib.request.Request(base + "/models", headers={"User-Agent": f"urok/{__version__}"})
|
|
43
|
+
if key:
|
|
44
|
+
request.add_header("Authorization", f"Bearer {key}")
|
|
45
|
+
with urllib.request.urlopen(request, timeout=15) as response:
|
|
46
|
+
data = json.loads(response.read().decode("utf-8", "replace"))
|
|
47
|
+
return sorted(item.get("id", "?") for item in data.get("data", []))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def chat(messages, base=None, model=None, key=None, timeout=300, on_token=None):
|
|
51
|
+
"""Отправляет диалог и возвращает полный ответ. on_token(text) — на каждый кусок."""
|
|
52
|
+
base, model, key = resolve(base, model, key)
|
|
53
|
+
url = base + "/chat/completions"
|
|
54
|
+
payload = {
|
|
55
|
+
"model": model,
|
|
56
|
+
"messages": messages,
|
|
57
|
+
"stream": bool(on_token),
|
|
58
|
+
}
|
|
59
|
+
if not on_token:
|
|
60
|
+
payload["stream"] = False
|
|
61
|
+
|
|
62
|
+
request = urllib.request.Request(
|
|
63
|
+
url,
|
|
64
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
65
|
+
headers=_headers(key),
|
|
66
|
+
method="POST",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
response = urllib.request.urlopen(request, timeout=timeout)
|
|
71
|
+
except urllib.error.HTTPError as exc:
|
|
72
|
+
raise LLMError(_http_hint(exc, base, model)) from exc
|
|
73
|
+
except urllib.error.URLError as exc:
|
|
74
|
+
raise LLMError(_offline_hint(exc, base)) from exc
|
|
75
|
+
except TimeoutError as exc: # на 3.9 socket.timeout — не TimeoutError
|
|
76
|
+
raise LLMError(
|
|
77
|
+
f"Сервер {base} не ответил за {timeout} с. Попробуй /model чтобы сменить модель."
|
|
78
|
+
) from exc
|
|
79
|
+
|
|
80
|
+
with response:
|
|
81
|
+
if not on_token:
|
|
82
|
+
return _full_text(_read_json(response))
|
|
83
|
+
return _read_stream(response, on_token)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _headers(key):
|
|
87
|
+
# Без явного User-Agent urllib шлёт "Python-urllib/3.x", а Zen режет его
|
|
88
|
+
# на входе с 403. Свой UA проходит.
|
|
89
|
+
headers = {
|
|
90
|
+
"Content-Type": "application/json",
|
|
91
|
+
"Accept": "text/event-stream",
|
|
92
|
+
"User-Agent": f"urok/{__version__}",
|
|
93
|
+
}
|
|
94
|
+
if key:
|
|
95
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
96
|
+
return headers
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _read_json(response):
|
|
100
|
+
raw = response.read().decode("utf-8", "replace")
|
|
101
|
+
try:
|
|
102
|
+
return json.loads(raw)
|
|
103
|
+
except json.JSONDecodeError as exc:
|
|
104
|
+
raise LLMError(f"Сервер вернул не-JSON: {raw[:200]}") from exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _full_text(data):
|
|
108
|
+
if "error" in data:
|
|
109
|
+
raise LLMError(_api_error(data["error"]))
|
|
110
|
+
choices = data.get("choices") or []
|
|
111
|
+
if not choices:
|
|
112
|
+
return ""
|
|
113
|
+
return (choices[0].get("message") or {}).get("content") or ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _read_stream(response, on_token):
|
|
117
|
+
pieces = []
|
|
118
|
+
for raw_line in response:
|
|
119
|
+
line = raw_line.decode("utf-8", "replace").strip()
|
|
120
|
+
if not line.startswith("data:"):
|
|
121
|
+
continue
|
|
122
|
+
data = line[5:].strip()
|
|
123
|
+
if data == "[DONE]":
|
|
124
|
+
break
|
|
125
|
+
try:
|
|
126
|
+
chunk = json.loads(data)
|
|
127
|
+
except json.JSONDecodeError:
|
|
128
|
+
continue
|
|
129
|
+
if "error" in chunk:
|
|
130
|
+
raise LLMError(_api_error(chunk["error"]))
|
|
131
|
+
token = _delta(chunk)
|
|
132
|
+
if token:
|
|
133
|
+
pieces.append(token)
|
|
134
|
+
on_token(token)
|
|
135
|
+
return "".join(pieces)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _delta(chunk):
|
|
139
|
+
choices = chunk.get("choices") or []
|
|
140
|
+
if not choices:
|
|
141
|
+
return ""
|
|
142
|
+
choice = choices[0]
|
|
143
|
+
# /v1/ отдаёт delta, нативный эндпоинт Ollama — message.
|
|
144
|
+
part = choice.get("delta") or choice.get("message") or {}
|
|
145
|
+
return part.get("content") or ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _api_error(error):
|
|
149
|
+
if isinstance(error, dict):
|
|
150
|
+
return error.get("message") or json.dumps(error, ensure_ascii=False)
|
|
151
|
+
return str(error)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _http_hint(exc, base, model):
|
|
155
|
+
detail = _api_error_from_body(exc)
|
|
156
|
+
if exc.code == 401:
|
|
157
|
+
return f"401: сервер {base} не пустил. Zen принимает ключ 'public'."
|
|
158
|
+
if exc.code == 403:
|
|
159
|
+
return (
|
|
160
|
+
f"403: {base} отклонил запрос. Обычно это фильтр по заголовкам — "
|
|
161
|
+
"проверь, не подменён ли User-Agent, и попробуй /base с другим адресом."
|
|
162
|
+
)
|
|
163
|
+
if exc.code == 404:
|
|
164
|
+
return f"404: модель '{model}' не найдена на {base}. Посмотри список: /models"
|
|
165
|
+
if exc.code == 429:
|
|
166
|
+
return "429: лимит запросов. Подожди немного."
|
|
167
|
+
return f"{exc.code}: {detail}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _api_error_from_body(exc):
|
|
171
|
+
try:
|
|
172
|
+
return _api_error(json.loads(exc.read().decode("utf-8", "replace")).get("error"))
|
|
173
|
+
except Exception:
|
|
174
|
+
return exc.reason or "ошибка сервера"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _offline_hint(exc, base):
|
|
178
|
+
reason = getattr(exc, "reason", exc)
|
|
179
|
+
if "127.0.0.1" in base or "localhost" in base:
|
|
180
|
+
return f"Локальный сервер {base} не отвечает (причина: {reason}). Он должен быть запущен."
|
|
181
|
+
return (
|
|
182
|
+
f"Нет связи с {base} (причина: {reason}).\n"
|
|
183
|
+
"Проверь интернет. Если адрес нестандартный — задай его флагом --base."
|
|
184
|
+
)
|
urok/store.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""История диалогов в ~/.urok — по одному JSON-файлу на сессию."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def folder() -> Path:
|
|
12
|
+
"""Путь к папке данных, без создания. Чтобы эфемерный режим вообще
|
|
13
|
+
не трогал диск — даже mkdir."""
|
|
14
|
+
base = os.environ.get("UROK_HOME")
|
|
15
|
+
return Path(base) if base else Path.home() / ".urok"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def home() -> Path:
|
|
19
|
+
path = folder()
|
|
20
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
return path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def new_session() -> Path:
|
|
25
|
+
return home() / f"session-{time.strftime('%Y%m%d-%H%M%S')}.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load(path: Path):
|
|
29
|
+
"""Читает сессию. Битый файл не роняет программу — просто начинаем заново."""
|
|
30
|
+
if not path.exists():
|
|
31
|
+
return []
|
|
32
|
+
rows = []
|
|
33
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
34
|
+
line = line.strip()
|
|
35
|
+
if not line:
|
|
36
|
+
continue
|
|
37
|
+
try:
|
|
38
|
+
rows.append(json.loads(line))
|
|
39
|
+
except json.JSONDecodeError:
|
|
40
|
+
continue
|
|
41
|
+
return rows
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def append(path: Path, role: str, content: str):
|
|
45
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
46
|
+
handle.write(json.dumps({"role": role, "content": content}, ensure_ascii=False) + "\n")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def list_sessions(limit=20):
|
|
50
|
+
files = sorted(home().glob("session-*.json"), reverse=True)
|
|
51
|
+
return files[:limit]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def system_prompt(grade: str = "", subject: str = "") -> str:
|
|
55
|
+
"""Системный промпт по умолчанию: отвечать по делу, не выдумывать факты."""
|
|
56
|
+
parts = [
|
|
57
|
+
"Ты помощник школьника. Отвечай по-русски, кратко и по делу.",
|
|
58
|
+
"Не выдумывай факты, формулы, цитаты и номера заданий. Не знаешь — скажи прямо.",
|
|
59
|
+
"Пиши формулы простым текстом: 3/4, x^2, корень из 5. "
|
|
60
|
+
"Не используй разметку LaTeX (\\[, \\(, $) — в терминале она не читается.",
|
|
61
|
+
"Без таблиц и без ASCII-картинок. Списки — короткие строки через дефис.",
|
|
62
|
+
]
|
|
63
|
+
if grade:
|
|
64
|
+
parts.append(f"Уровень: {grade} класс.")
|
|
65
|
+
if subject:
|
|
66
|
+
parts.append(f"Предмет: {subject}.")
|
|
67
|
+
return " ".join(parts)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def wipe():
|
|
71
|
+
"""Удаляет папку с историей. Возвращает (сколько сессий, было ли что удалять)."""
|
|
72
|
+
import shutil
|
|
73
|
+
|
|
74
|
+
folder_path = folder()
|
|
75
|
+
if not folder_path.is_dir():
|
|
76
|
+
return 0, False
|
|
77
|
+
sessions = list(folder_path.glob("session-*.json"))
|
|
78
|
+
if not sessions:
|
|
79
|
+
return 0, False
|
|
80
|
+
shutil.rmtree(folder_path, ignore_errors=True)
|
|
81
|
+
return len(sessions), True
|
|
82
|
+
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: urok
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Консольный помощник для учёбы: объясняет темы, проверяет домашку, помогает разобраться
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: chat,cli,homework,ollama,study
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Intended Audience :: Education
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# urok
|
|
14
|
+
|
|
15
|
+
Консольный помощник для учёбы. Объясняет темы, проверяет домашку, помогает
|
|
16
|
+
разобраться в том, что не понял.
|
|
17
|
+
|
|
18
|
+
Работает сразу после установки: бэкенд — OpenCode Zen, модель
|
|
19
|
+
`space-bunny-free`, ключ `public`. Ничего настраивать не нужно, ключ не нужен.
|
|
20
|
+
|
|
21
|
+
Ноль зависимостей — только стандартная библиотека Python.
|
|
22
|
+
|
|
23
|
+
## Установка
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
После установки в любой папке работает команда `urok`.
|
|
30
|
+
|
|
31
|
+
## Запуск под конкретный урок
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
urok --grade 9 --subject физика
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Модель сразу настроится на девятый класс и физику. Можно менять на лету: `/system`.
|
|
38
|
+
|
|
39
|
+
## Команды
|
|
40
|
+
|
|
41
|
+
| Команда | Что делает |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `/help` | список команд |
|
|
44
|
+
| `/exit` | выйти, Ctrl+D тоже |
|
|
45
|
+
| `/clear` | забыть текущий диалог |
|
|
46
|
+
| `/model` | какая модель сейчас |
|
|
47
|
+
| `/model имя` | переключить модель прямо сейчас |
|
|
48
|
+
| `/models` | список моделей на сервере |
|
|
49
|
+
| `/base` | адрес сервера |
|
|
50
|
+
| `/base адрес` | сменить сервер |
|
|
51
|
+
| `/system` | показать или сменить системный промпт |
|
|
52
|
+
| `/wipe` | удалить всю сохранённую историю |
|
|
53
|
+
| `/load` | продолжить прошлую сессию (нужен `--keep-history`) |
|
|
54
|
+
| `/sessions` | список сохранённых сессий |
|
|
55
|
+
| `/copy` | скопировать последний ответ в буфер (Windows) |
|
|
56
|
+
|
|
57
|
+
Ctrl+C во время ответа прерывает только этот ответ — диалог остаётся.
|
|
58
|
+
|
|
59
|
+
## История по умолчанию не пишется
|
|
60
|
+
|
|
61
|
+
Закрыл консоль — на диске ничего не осталось. Проверено: папка `~/.urok` не
|
|
62
|
+
создаётся вообще, даже `mkdir` не вызывается. При выходе печатается проверка:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
на диск ничего не записано, следов нет
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Если папка всё же есть от прошлых запусков с `--keep-history` — выход скажет
|
|
69
|
+
об этом прямо и предложит `/wipe`.
|
|
70
|
+
|
|
71
|
+
Запомнить диалог между запусками:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
urok --keep-history
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Тогда история пишется в `~/.urok/session-ГГГГММДД-ЧЧММСС.json`, продолжить —
|
|
78
|
+
`urok --keep-history --load 1`.
|
|
79
|
+
|
|
80
|
+
## Что остаётся на диске помимо истории
|
|
81
|
+
|
|
82
|
+
Честно, чтобы не было сюрпризов. Пакет убирает за собой только историю диалога.
|
|
83
|
+
Это остаётся и находится вне его досягаемости:
|
|
84
|
+
|
|
85
|
+
| След | Где | Как убрать |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| сам пакет | `site-packages/urok` | `pip uninstall urok` |
|
|
88
|
+
| байт-код | `site-packages/urok/__pycache__` | `pip uninstall urok` |
|
|
89
|
+
| история команд PowerShell | `ConsoleHost_history.txt` | Clear-History и удаление файла |
|
|
90
|
+
| сетевой лог | DNS/прокси школной сети | вне твоего контроля |
|
|
91
|
+
|
|
92
|
+
Запросы к `opencode.ai` видны сетевому логу в любом случае — это происходит
|
|
93
|
+
независимо от того, что лежит на диске. Эфемерный режим чистит локальные следы,
|
|
94
|
+
а не сетевые.
|
|
95
|
+
|
|
96
|
+
## Другие бесплатные модели
|
|
97
|
+
|
|
98
|
+
На Zen их несколько, все с суффиксом `-free`:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
space-bunny-free по умолчанию
|
|
102
|
+
deepseek-v4-flash-free
|
|
103
|
+
muse-spark-1.3-contributor-free
|
|
104
|
+
muse-spark-1.2-contributor-free
|
|
105
|
+
mimo-v2.5-free
|
|
106
|
+
nemotron-3-ultra-free
|
|
107
|
+
longcat-2.5-preview-free
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Полный список: `/models`. Переключить: `/model muse-spark-1.3-contributor-free`
|
|
111
|
+
или флагом `urok --model deepseek-v4-flash-free`.
|
|
112
|
+
|
|
113
|
+
## Другой провайдер
|
|
114
|
+
|
|
115
|
+
Через флаги:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
urok --base https://api.groq.com/openai/v1 --key gsk_... --model llama-3.3-70b-versatile
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Или через переменные окружения, чтобы не светить ключ в истории:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
set UROK_BASE_URL=https://api.groq.com/openai/v1
|
|
125
|
+
set UROK_API_KEY=gsk_...
|
|
126
|
+
set UROK_MODEL=llama-3.3-70b-versatile
|
|
127
|
+
urok
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Подходит любой OpenAI-совместимый адрес: Groq, OpenAI, LM Studio, Ollama,
|
|
131
|
+
llama.cpp на `http://127.0.0.1:8080/v1`.
|
|
132
|
+
|
|
133
|
+
## Где лежат данные
|
|
134
|
+
|
|
135
|
+
История по умолчанию не пишется. С флагом `--keep-history` диалог сохраняется
|
|
136
|
+
в `~/.urok/session-ГГГГММДД-ЧЧММСС.json`, по строке на сообщение.
|
|
137
|
+
Сменить папку: переменная `UROK_HOME`.
|
|
138
|
+
|
|
139
|
+
## Настройка своего промпта
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
urok --system "Объясняй как пятикласснику, только формулы и короткие примеры."
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Тесты
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
python tests/smoke.py
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Поднимает фейковый API в потоке и прогоняет через него весь диалог.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
urok/__init__.py,sha256=IMnqO2q_JlajuDMQq65g-JPoupYL7WS1RkdGY0b27IE,95
|
|
2
|
+
urok/__main__.py,sha256=aCil-iFaToZsL_hBt2MdL_sZiMgDtaELunVGHWfOhmk,83
|
|
3
|
+
urok/cli.py,sha256=7FIVEUJJciuEkQDGf5VwYoJCcIKiby1owFNwaD2M8CE,9879
|
|
4
|
+
urok/llm.py,sha256=NrGWjzgJiGQ5bxntutkVWM0UvYK2nt9kiF_ETLZnwVM,6821
|
|
5
|
+
urok/store.py,sha256=IYsNq16wou-jnqYY_Q-y8sJ8hnxGewNccc-r0qC5UAc,2974
|
|
6
|
+
urok-0.1.0.dist-info/METADATA,sha256=576ZwBkHZb6UGPXPgpA0gP8xO2Cnbuqn97Dg8uTTXWk,6059
|
|
7
|
+
urok-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
urok-0.1.0.dist-info/entry_points.txt,sha256=HXbdNP-X6iOBB63oFjbwljJ0xhOau1qou6TfmNg_nKs,39
|
|
9
|
+
urok-0.1.0.dist-info/RECORD,,
|