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 +8 -0
- urok-0.1.0/PKG-INFO +151 -0
- urok-0.1.0/README.md +139 -0
- urok-0.1.0/pyproject.toml +33 -0
- urok-0.1.0/src/urok/__init__.py +3 -0
- urok-0.1.0/src/urok/__main__.py +4 -0
- urok-0.1.0/src/urok/cli.py +268 -0
- urok-0.1.0/src/urok/llm.py +184 -0
- urok-0.1.0/src/urok/store.py +82 -0
- urok-0.1.0/tests/fake_server.py +78 -0
- urok-0.1.0/tests/smoke.py +195 -0
urok-0.1.0/.gitignore
ADDED
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,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())
|