urok 0.1.0__tar.gz

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-0.1.0/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ *.log
8
+ *.log.err
urok-0.1.0/PKG-INFO ADDED
@@ -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 в потоке и прогоняет через него весь диалог.
urok-0.1.0/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # urok
2
+
3
+ Консольный помощник для учёбы. Объясняет темы, проверяет домашку, помогает
4
+ разобраться в том, что не понял.
5
+
6
+ Работает сразу после установки: бэкенд — OpenCode Zen, модель
7
+ `space-bunny-free`, ключ `public`. Ничего настраивать не нужно, ключ не нужен.
8
+
9
+ Ноль зависимостей — только стандартная библиотека Python.
10
+
11
+ ## Установка
12
+
13
+ ```bash
14
+ pip install .
15
+ ```
16
+
17
+ После установки в любой папке работает команда `urok`.
18
+
19
+ ## Запуск под конкретный урок
20
+
21
+ ```bash
22
+ urok --grade 9 --subject физика
23
+ ```
24
+
25
+ Модель сразу настроится на девятый класс и физику. Можно менять на лету: `/system`.
26
+
27
+ ## Команды
28
+
29
+ | Команда | Что делает |
30
+ | --- | --- |
31
+ | `/help` | список команд |
32
+ | `/exit` | выйти, Ctrl+D тоже |
33
+ | `/clear` | забыть текущий диалог |
34
+ | `/model` | какая модель сейчас |
35
+ | `/model имя` | переключить модель прямо сейчас |
36
+ | `/models` | список моделей на сервере |
37
+ | `/base` | адрес сервера |
38
+ | `/base адрес` | сменить сервер |
39
+ | `/system` | показать или сменить системный промпт |
40
+ | `/wipe` | удалить всю сохранённую историю |
41
+ | `/load` | продолжить прошлую сессию (нужен `--keep-history`) |
42
+ | `/sessions` | список сохранённых сессий |
43
+ | `/copy` | скопировать последний ответ в буфер (Windows) |
44
+
45
+ Ctrl+C во время ответа прерывает только этот ответ — диалог остаётся.
46
+
47
+ ## История по умолчанию не пишется
48
+
49
+ Закрыл консоль — на диске ничего не осталось. Проверено: папка `~/.urok` не
50
+ создаётся вообще, даже `mkdir` не вызывается. При выходе печатается проверка:
51
+
52
+ ```
53
+ на диск ничего не записано, следов нет
54
+ ```
55
+
56
+ Если папка всё же есть от прошлых запусков с `--keep-history` — выход скажет
57
+ об этом прямо и предложит `/wipe`.
58
+
59
+ Запомнить диалог между запусками:
60
+
61
+ ```bash
62
+ urok --keep-history
63
+ ```
64
+
65
+ Тогда история пишется в `~/.urok/session-ГГГГММДД-ЧЧММСС.json`, продолжить —
66
+ `urok --keep-history --load 1`.
67
+
68
+ ## Что остаётся на диске помимо истории
69
+
70
+ Честно, чтобы не было сюрпризов. Пакет убирает за собой только историю диалога.
71
+ Это остаётся и находится вне его досягаемости:
72
+
73
+ | След | Где | Как убрать |
74
+ | --- | --- | --- |
75
+ | сам пакет | `site-packages/urok` | `pip uninstall urok` |
76
+ | байт-код | `site-packages/urok/__pycache__` | `pip uninstall urok` |
77
+ | история команд PowerShell | `ConsoleHost_history.txt` | Clear-History и удаление файла |
78
+ | сетевой лог | DNS/прокси школной сети | вне твоего контроля |
79
+
80
+ Запросы к `opencode.ai` видны сетевому логу в любом случае — это происходит
81
+ независимо от того, что лежит на диске. Эфемерный режим чистит локальные следы,
82
+ а не сетевые.
83
+
84
+ ## Другие бесплатные модели
85
+
86
+ На Zen их несколько, все с суффиксом `-free`:
87
+
88
+ ```
89
+ space-bunny-free по умолчанию
90
+ deepseek-v4-flash-free
91
+ muse-spark-1.3-contributor-free
92
+ muse-spark-1.2-contributor-free
93
+ mimo-v2.5-free
94
+ nemotron-3-ultra-free
95
+ longcat-2.5-preview-free
96
+ ```
97
+
98
+ Полный список: `/models`. Переключить: `/model muse-spark-1.3-contributor-free`
99
+ или флагом `urok --model deepseek-v4-flash-free`.
100
+
101
+ ## Другой провайдер
102
+
103
+ Через флаги:
104
+
105
+ ```bash
106
+ urok --base https://api.groq.com/openai/v1 --key gsk_... --model llama-3.3-70b-versatile
107
+ ```
108
+
109
+ Или через переменные окружения, чтобы не светить ключ в истории:
110
+
111
+ ```bash
112
+ set UROK_BASE_URL=https://api.groq.com/openai/v1
113
+ set UROK_API_KEY=gsk_...
114
+ set UROK_MODEL=llama-3.3-70b-versatile
115
+ urok
116
+ ```
117
+
118
+ Подходит любой OpenAI-совместимый адрес: Groq, OpenAI, LM Studio, Ollama,
119
+ llama.cpp на `http://127.0.0.1:8080/v1`.
120
+
121
+ ## Где лежат данные
122
+
123
+ История по умолчанию не пишется. С флагом `--keep-history` диалог сохраняется
124
+ в `~/.urok/session-ГГГГММДД-ЧЧММСС.json`, по строке на сообщение.
125
+ Сменить папку: переменная `UROK_HOME`.
126
+
127
+ ## Настройка своего промпта
128
+
129
+ ```bash
130
+ urok --system "Объясняй как пятикласснику, только формулы и короткие примеры."
131
+ ```
132
+
133
+ ## Тесты
134
+
135
+ ```bash
136
+ python tests/smoke.py
137
+ ```
138
+
139
+ Поднимает фейковый API в потоке и прогоняет через него весь диалог.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "urok"
7
+ version = "0.1.0"
8
+ description = "Консольный помощник для учёбы: объясняет темы, проверяет домашку, помогает разобраться"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["study", "chat", "cli", "ollama", "homework"]
13
+ classifiers = [
14
+ "Environment :: Console",
15
+ "Intended Audience :: Education",
16
+ "Programming Language :: Python :: 3",
17
+ ]
18
+
19
+ # Ноль зависимостей: весь код на стандартной библиотеке.
20
+ # pip install urok не тянет ничего лишнего, ставится в профиль пользователя.
21
+ dependencies = []
22
+
23
+ [project.scripts]
24
+ urok = "urok.cli:main"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/urok"]
28
+
29
+ # Без явного списка hatchling тащит в sdist всё подряд, включая .venv*.
30
+ # На чистой машине без .git .gitignore не спасает — 3.4 МБ мусора вместо 20 КБ.
31
+ [tool.hatch.build.targets.sdist]
32
+ include = ["/src", "/tests", "/README.md", "/pyproject.toml", "/.gitignore"]
33
+ exclude = ["/.venv*", "/dist", "/build", "**/__pycache__", "**/*.pyc", "**/*.log"]
@@ -0,0 +1,3 @@
1
+ """urok — консольный помощник для учёбы."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from urok.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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())
@@ -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
+ )
@@ -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,78 @@
1
+ """Фейковый OpenAI-совместимый сервер для проверки, что клиент реально работает.
2
+
3
+ Запуск: python fake_server.py 11435
4
+ Проверка: urok --base http://127.0.0.1:11435/v1 --model test --no-history
5
+ """
6
+
7
+ import json
8
+ import sys
9
+ import threading
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+
12
+ REPLY = "Потенциальная энергия равна m*g*h. Если масса 2 кг, высота 3 м, g=10 — 60 Дж."
13
+
14
+
15
+ class Handler(BaseHTTPRequestHandler):
16
+ protocol_version = "HTTP/1.1"
17
+
18
+ def log_message(self, *args):
19
+ pass
20
+
21
+ def do_GET(self):
22
+ if self.path.endswith("/models"):
23
+ self._require_user_agent()
24
+ body = json.dumps({"data": [{"id": "test"}, {"id": "llama3.2"}]}).encode()
25
+ self._send(200, body, "application/json")
26
+ else:
27
+ self._send(404, b"not found", "text/plain")
28
+
29
+ def do_POST(self):
30
+ self._require_user_agent()
31
+ length = int(self.headers.get("Content-Length", 0))
32
+ payload = json.loads(self.rfile.read(length) or b"{}")
33
+ print(f"[server] модель={payload.get('model')} сообщений={len(payload.get('messages', []))}",
34
+ flush=True)
35
+
36
+ if payload.get("stream"):
37
+ self._stream(REPLY)
38
+ else:
39
+ body = json.dumps({
40
+ "choices": [{"message": {"role": "assistant", "content": REPLY}}]
41
+ }).encode()
42
+ self._send(200, body, "application/json")
43
+
44
+ def _require_user_agent(self):
45
+ """Zen режет Python-urllib на 403. Здесь тот же фильтр — проверяем,
46
+ что клиент шлёт свой User-Agent."""
47
+ agent = self.headers.get("User-Agent", "")
48
+ if agent.startswith("Python-urllib"):
49
+ self._send(403, b"blocked user agent", "text/plain")
50
+ return False
51
+ return True
52
+
53
+ def _stream(self, text):
54
+ self.send_response(200)
55
+ self.send_header("Content-Type", "text/event-stream")
56
+ self.send_header("Transfer-Encoding", "chunked")
57
+ self.end_headers()
58
+ for piece in text.split(" "):
59
+ frame = ("data: " + json.dumps(
60
+ {"choices": [{"delta": {"content": piece + " "}}]}) + "\n\n").encode()
61
+ self.wfile.write(b"%x\r\n%s\r\n" % (len(frame), frame))
62
+ done = b"data: [DONE]\n\n"
63
+ self.wfile.write(b"%x\r\n%s\r\n" % (len(done), done))
64
+ self.wfile.write(b"0\r\n\r\n")
65
+
66
+ def _send(self, code, body, content_type):
67
+ self.send_response(code)
68
+ self.send_header("Content-Type", content_type)
69
+ self.send_header("Content-Length", str(len(body)))
70
+ self.end_headers()
71
+ self.wfile.write(body)
72
+
73
+
74
+ if __name__ == "__main__":
75
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 11435
76
+ server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
77
+ print(f"[server] слушаю http://127.0.0.1:{port}/v1", flush=True)
78
+ server.serve_forever()
@@ -0,0 +1,195 @@
1
+ """Живая проверка: поднимает фейковый API в потоке и гоняет через него urok.
2
+
3
+ Проверяет настоящий путь: HTTP-запрос, стриминг, разбор SSE, запись истории.
4
+ Запуск: python tests/smoke.py
5
+ """
6
+
7
+ import io
8
+ import sys
9
+ import threading
10
+ from contextlib import redirect_stdout
11
+ from pathlib import Path
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
15
+
16
+ import fake_server # noqa: E402
17
+ from http.server import ThreadingHTTPServer # noqa: E402
18
+
19
+ from urok import cli, store # noqa: E402
20
+
21
+ PORT = 11436
22
+ BASE = f"http://127.0.0.1:{PORT}/v1"
23
+
24
+ checks = []
25
+
26
+
27
+ def check(name, ok, detail=""):
28
+ checks.append((name, ok, detail))
29
+ print(f" [{'OK ' if ok else 'FAIL'}] {name}{(' -> ' + detail) if detail and not ok else ''}")
30
+
31
+
32
+ def run_cli(answers, extra_args):
33
+ """Прогоняет REPL с подставленным вводом, возвращает stdout."""
34
+ lines = iter(answers)
35
+
36
+ def fake_input(_prompt=""):
37
+ try:
38
+ return next(lines)
39
+ except StopIteration:
40
+ raise EOFError
41
+
42
+ cli_input_backup = __builtins__["input"] if isinstance(__builtins__, dict) else __builtins__.input
43
+ import builtins
44
+
45
+ builtins.input = fake_input
46
+ buffer = io.StringIO()
47
+ try:
48
+ with redirect_stdout(buffer):
49
+ code = cli.main(extra_args)
50
+ finally:
51
+ builtins.input = cli_input_backup
52
+ return code, buffer.getvalue()
53
+
54
+
55
+ def main():
56
+ server = ThreadingHTTPServer(("127.0.0.1", PORT), fake_server.Handler)
57
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
58
+ thread.start()
59
+ print(f"фейковый API на {BASE}\n")
60
+
61
+ home = Path.home() / ".urok-smoke"
62
+ import os
63
+
64
+ os.environ["UROK_HOME"] = str(home)
65
+
66
+ print("1. --help отрабатывает")
67
+ try:
68
+ cli.build_parser().parse_args(["--grade", "9"])
69
+ check("аргументы парсятся", True)
70
+ except SystemExit:
71
+ check("аргументы парсятся", False, "argparse упал")
72
+
73
+ print("\n2. диалог со стримингом + история (--keep-history)")
74
+ code, out = run_cli(
75
+ ["высота 3 м, масса 2 кг", "/clear", "а что такое мощность", "/exit"],
76
+ ["--base", BASE, "--model", "test", "--grade", "9", "--subject", "физика",
77
+ "--keep-history"],
78
+ )
79
+ check("код возврата 0", code == 0, f"код={code}")
80
+ check("ответ пришёл в вывод", fake_server.REPLY[:20] in out, out[-200:])
81
+ check("печатается подпись сервера", "сервер отвечает" in out, out[:300])
82
+ check("модель в шапке", "модель: test" in out)
83
+
84
+ print("\n3. история записалась на диск")
85
+ sessions = sorted(home.glob("session-*.json"))
86
+ check("файл сессии создан", bool(sessions), "нет файлов")
87
+ if sessions:
88
+ text = sessions[-1].read_text(encoding="utf-8")
89
+ check("есть реплика ученика", "высота 3 м" in text)
90
+ check("есть ответ модели", "Потенциальная энергия" in text)
91
+ check("/clear обнулил диалог", "а что такое мощность" not in out.split("диалог очищен")[-1][:60])
92
+
93
+ print("\n4. /load тянет прошлую сессию")
94
+ code, out2 = run_cli(["/load", "/sessions", "/exit"], ["--base", BASE, "--model", "test"])
95
+ check("/sessions печатает файл", "session-" in out2, out2[:200])
96
+ check("/load отчитался о числе сообщений", "загружено сообщений" in out2, out2[:300])
97
+
98
+ print("\n5. недоступный сервер даёт понятную подсказку")
99
+ code, out3 = run_cli(["/exit"], ["--base", "http://127.0.0.1:11999/v1", "--model", "test"])
100
+ check("не падает", code == 0, f"код={code}")
101
+ check("есть текст про сервер", "не отвечает" in out3, out3[:200])
102
+
103
+ print("\n6. неизвестная команда не роняет REPL")
104
+ code, out4 = run_cli(["/такая", "/exit"], ["--base", BASE, "--model", "test"])
105
+ check("подсказал про /help", "неизвестная команда" in out4, out4[:200])
106
+
107
+ print("\n7. не-текстовый ответ от сервера не ломает парсер SSE")
108
+ handler_ok = _check_junk_stream(server)
109
+ check("мусорные кадры пропущены", handler_ok)
110
+
111
+ print("\n8. клиент шлёт свой User-Agent (Zen режет Python-urllib на 403)")
112
+ ua = _client_user_agent()
113
+ check("User-Agent не дефолтный urllib", not ua.startswith("Python-urllib"), ua)
114
+ check("User-Agent идентифицирует urok", ua.startswith("urok/"), ua)
115
+ check("список моделей проходит", "test" in _models_through_client())
116
+
117
+ print("\n9. эфемерный режим: на диск не пишется ничего (по умолчанию)")
118
+ fresh = Path.home() / ".urok-smoke-fresh"
119
+ if fresh.exists():
120
+ import shutil
121
+
122
+ shutil.rmtree(fresh)
123
+ os.environ["UROK_HOME"] = str(fresh)
124
+ code, out5 = run_cli(["привет", "/exit"], ["--base", BASE, "--model", "test"])
125
+ check("код возврата 0", code == 0, f"код={code}")
126
+ check("папка не создана вообще", not fresh.exists(), f"создана {fresh}")
127
+ check("ответил на вопрос", fake_server.REPLY[:20] in out5)
128
+ check("на выходе отчитался о чистоте", "ничего не записано" in out5, out5[-200:])
129
+ check("в шапке сказано про историю", "не сохраняется" in out5, out5[:300])
130
+
131
+ print("\n10. /sessions и /load честно отвечают, что истории нет")
132
+ code, out6 = run_cli(["/sessions", "/load", "/exit"], ["--base", BASE, "--model", "test"])
133
+ check("/sessions сказал пусто", "пусто" in out6, out6[:200])
134
+ check("/load объяснил про --keep-history", "--keep-history" in out6, out6[:300])
135
+
136
+ print("\n11. /wipe удаляет папку с историей")
137
+ os.environ["UROK_HOME"] = str(home)
138
+ check("до /wipe сессии есть", _history_on(home))
139
+ code, out7 = run_cli(["/wipe", "/exit"], ["--base", BASE, "--model", "test"])
140
+ check("отчитался об удалении", "удалено сессий" in out7, out7[:200])
141
+ check("папка исчезла", not home.exists(), f"осталась {home}")
142
+
143
+ print("\n12. удалённой строки нет в системном промпте")
144
+ from urok import store as store_mod
145
+
146
+ prompt = store_mod.system_prompt("9", "математика")
147
+ check("нет про 'не делай за ученика'", "не делай за ученика" not in prompt, prompt)
148
+ check("есть запрет LaTeX", "LaTeX" in prompt, prompt)
149
+ check("есть запрет выдумывать", "Не выдумывай" in prompt, prompt)
150
+
151
+ server.shutdown()
152
+
153
+ failed = [c for c in checks if not c[1]]
154
+ print(f"\n{'=' * 46}")
155
+ print(f"итог: {len(checks) - len(failed)}/{len(checks)} проверок прошли")
156
+ if failed:
157
+ for name, _, detail in failed:
158
+ print(f" упало: {name} {detail}")
159
+ return 1
160
+ return 0
161
+
162
+
163
+ def _check_junk_stream(server):
164
+ """Кадр не-JSON и пустая строка не должны ронять стриминг."""
165
+ import json
166
+ import urllib.request
167
+
168
+ req = urllib.request.Request(
169
+ BASE + "/chat/completions",
170
+ data=json.dumps({"model": "test", "messages": [], "stream": True}).encode(),
171
+ headers={"User-Agent": "urok-smoke/0.1.0"},
172
+ )
173
+ with urllib.request.urlopen(req, timeout=10) as response:
174
+ body = response.read().decode()
175
+ return "[DONE]" in body
176
+
177
+
178
+ def _history_on(folder_path):
179
+ return any(folder_path.glob("session-*.json")) if folder_path.is_dir() else False
180
+
181
+
182
+ def _client_user_agent():
183
+ from urok import llm
184
+
185
+ return llm._headers("k").get("User-Agent", "")
186
+
187
+
188
+ def _models_through_client():
189
+ from urok import llm
190
+
191
+ return " ".join(llm.list_models(BASE, "k"))
192
+
193
+
194
+ if __name__ == "__main__":
195
+ raise SystemExit(main())