обновлен формат генерации датасета
This commit is contained in:
@@ -0,0 +1,386 @@
|
|||||||
|
# LLM Robot Control
|
||||||
|
|
||||||
|
Генератор синтетических датасетов для обучения моделей высокоуровневому
|
||||||
|
планированию промышленной сборки. Создаёт структурированные сцены, строит планы,
|
||||||
|
проверяет предусловия действий и достижение цели, экспортирует обучающие записи
|
||||||
|
в JSONL. Для каждого примера сохраняются два варианта ответа: русский текст и JSON с навыками.
|
||||||
|
Управление физическим роботом и расчёт траекторий не реализованы.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
Требуются Python 3.13+, uv и Linux (для блокировки файлов используется `fcntl`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Команды выполняются из корня репозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверить конфигурацию без запросов к модели и создания файлов
|
||||||
|
uv run python -m assembly_dataset --dry-run
|
||||||
|
|
||||||
|
# Создать 100 обучающих записей
|
||||||
|
uv run python -m assembly_dataset generate --count 100
|
||||||
|
|
||||||
|
# Продолжить корпус до 500 000 записей
|
||||||
|
uv run python -m assembly_dataset generate --count 500000
|
||||||
|
|
||||||
|
# Проверить сохранённые эпизоды и экспорт
|
||||||
|
uv run python -m assembly_dataset validate
|
||||||
|
```
|
||||||
|
|
||||||
|
`--count` задаёт точное суммарное число примеров по выбранным режимам.
|
||||||
|
Каждый пример экспортируется в двух вариантах: `--count 100` даёт 100 текстовых
|
||||||
|
и 100 структурированных записей с одинаковыми ID. `examples` и прогресс считают
|
||||||
|
пары один раз; `examples_per_variant` в manifest показывает размеры обоих вариантов.
|
||||||
|
Без этого аргумента используется `target_examples` из конфигурации.
|
||||||
|
Одна сцена может давать несколько записей; количество сцен выводится отдельно.
|
||||||
|
Ноль допустим; отрицательные и дробные значения запрещены.
|
||||||
|
|
||||||
|
`step_sampling: one` выбирает один воспроизводимый шаг из каждого эпизода.
|
||||||
|
`all` экспортирует каждый выполненный шаг. Общий лимит действует и в этом случае:
|
||||||
|
последний эпизод может быть экспортирован частично. Если нужен корпус из полных
|
||||||
|
планов для 500 000 исходных сцен, выберите `modes: [full]` и `--count 500000`.
|
||||||
|
При трёх форматах число сцен отображается отдельно как `scenarios`.
|
||||||
|
|
||||||
|
|
||||||
|
## Прогресс выполнения
|
||||||
|
|
||||||
|
При запуске CLI автоматически отображаются два индикатора: генерация записей
|
||||||
|
и экспорт в JSONL. Каждый показывает количество записей относительно цели,
|
||||||
|
процент выполнения, скорость, прошедшее и приблизительное оставшееся время.
|
||||||
|
При продолжении корпуса генерация начинается с уже сохранённого количества.
|
||||||
|
|
||||||
|
Счётчик генерации обновляется после сохранения пакета проверенных эпизодов,
|
||||||
|
а не после каждого API-запроса. Во время вызовов модели отображается
|
||||||
|
«ожидание LLM». Экспорт отдельно показывает запись строк и подготовку контрольных
|
||||||
|
сумм. Оценка времени появляется после первых обновлений и может меняться из-за
|
||||||
|
скорости модели, повторных попыток и длины планов.
|
||||||
|
|
||||||
|
В терминале полосы обновляются на месте; при перенаправлении вывода сохраняется
|
||||||
|
итоговая строка каждого этапа. Индикаторы выводятся в stderr, итоговая статистика
|
||||||
|
JSON — в stdout. Для отключения используйте `--no-progress`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m assembly_dataset generate --count 100 --no-progress
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Настройки находятся в [config.yaml](config.yaml):
|
||||||
|
|
||||||
|
- `target_examples` — размер итогового корпуса.
|
||||||
|
- `modes` — форматы `full`, `step`, `branch`.
|
||||||
|
- `step_sampling` — один шаг или все шаги эпизода.
|
||||||
|
- `seed`, `domains`, `min_components`, `max_components`, `blocked_fraction` —
|
||||||
|
параметры создания сцен.
|
||||||
|
- `split` — доли train, validation и test; записи одной сцены не разделяются.
|
||||||
|
- `shard_size` — максимальное число строк в одном файле JSONL.
|
||||||
|
- `output_dir` — папка результатов относительно файла конфигурации.
|
||||||
|
- `teacher` — провайдер, модель, доля сцен для LLM, параллельность и повторные попытки.
|
||||||
|
|
||||||
|
В поставляемом конфиге включён Codex для всех сцен. Для работы требуется доступ
|
||||||
|
к указанной модели. При отсутствии файла учётных данных запускается авторизация
|
||||||
|
через браузер. OpenAI-совместимые серверы настраиваются через `teacher.llm`;
|
||||||
|
ключ и адрес сервера берутся из переменных окружения или корневого `.env`.
|
||||||
|
Для генерации без API задайте `teacher.enabled: false`.
|
||||||
|
|
||||||
|
Другой конфиг передаётся через `--config PATH`, другая папка результатов — через
|
||||||
|
`--output-dir PATH`. Изменение параметров сцен или модели требует отдельной папки
|
||||||
|
корпуса. Лимит записей и размер шардов можно менять при продолжении запуска.
|
||||||
|
|
||||||
|
## Где что менять
|
||||||
|
|
||||||
|
| Задача | Файл и параметр |
|
||||||
|
|---|---|
|
||||||
|
| Размер датасета | `config.yaml` → `target_examples` или CLI `--count` |
|
||||||
|
| Модель и провайдер | `config.yaml` → `teacher.llm` |
|
||||||
|
| Сложность и типы сцен | `config.yaml` → `domains`, `min_components`, `max_components`, `blocked_fraction` |
|
||||||
|
| Инструкции генератору планов | `prompts.yaml` → `teacher_system` |
|
||||||
|
| Текст запроса со сценой | `prompts.yaml` → `teacher_user` |
|
||||||
|
| Запрос исправления ошибочного плана | `prompts.yaml` → `teacher_retry` |
|
||||||
|
| Инструкции обучаемой модели для JSON | `prompts.yaml` → `structured_system.full`, `.step`, `.branch` |
|
||||||
|
| Инструкции обучаемой модели для русского текста | `prompts.yaml` → `training_system.full`, `.step`, `.branch` |
|
||||||
|
| Текст входа обучающей записи | `prompts.yaml` → `training_user` |
|
||||||
|
| Формулировки заданий по типам сборки | `prompts.yaml` → `task_instructions` |
|
||||||
|
| Названия объектов | `prompts.yaml` → `object_names` |
|
||||||
|
| Русские описания для локального режима и просмотра сцены | `prompts.yaml` → `action_templates` |
|
||||||
|
|
||||||
|
В шаблонах `teacher_user`, `teacher_retry` и `training_user` нужно оставить
|
||||||
|
`${payload}`: сюда подставляется JSON с заданием и сценой. В запросе исправления
|
||||||
|
JSON также содержит `validation_error`. Остальной текст можно менять свободно.
|
||||||
|
|
||||||
|
После изменения промптов используйте новую папку, например
|
||||||
|
`--output-dir dataset/prompt-experiment`. Содержимое промптов сохраняется в настройках
|
||||||
|
запуска и проверяется при возобновлении, чтобы не смешивать разные версии корпуса.
|
||||||
|
Промпты меняют инструкции и формулировки, но не правила логической проверки.
|
||||||
|
|
||||||
|
Пример изменения запроса:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
teacher_user: |
|
||||||
|
Составь план выполнения следующего задания.
|
||||||
|
Учитывай доступность деталей и зависимости операций.
|
||||||
|
${payload}
|
||||||
|
```
|
||||||
|
|
||||||
|
`${payload}` обязателен в трёх шаблонах user/retry. Он заменяется JSON с полями
|
||||||
|
`instruction` и `scene`; при исправлении добавляется `validation_error`.
|
||||||
|
В обучающем запросе JSON также содержит `mode`, а для шага — `history`.
|
||||||
|
Обычные фигурные скобки разрешены; литеральный знак доллара записывается как `$$`.
|
||||||
|
Системные инструкции и `task_instructions` — обычный текст без подстановок.
|
||||||
|
|
||||||
|
Пустые инструкции и неизвестные переменные шаблона отклоняются до API-запроса.
|
||||||
|
Изменённые промпты генерации требуют отдельного `output_dir`.
|
||||||
|
Исключение — `structured_system`: эти инструкции используются только при экспорте,
|
||||||
|
поэтому их можно изменить и выполнить `export` в той же папке. Их полное содержимое сохраняется
|
||||||
|
в настройках корпуса. Формат ответа `Plan` и правила проверки остаются заданными
|
||||||
|
кодом: смена текста не добавляет новые навыки или поля JSON.
|
||||||
|
|
||||||
|
## Форматы и результаты
|
||||||
|
|
||||||
|
```text
|
||||||
|
dataset/assembly-ru/
|
||||||
|
episodes.sqlite3 # сцены, планы, история состояний, графы, provenance
|
||||||
|
sft/
|
||||||
|
manifest.json # точные размеры, распределения, SHA-256 файлов
|
||||||
|
train/full/000000.jsonl # ответ русским текстом
|
||||||
|
train/full/000000.structured.jsonl # ответ status/plan
|
||||||
|
train/step/000000.jsonl
|
||||||
|
train/branch/000000.jsonl
|
||||||
|
validation/...
|
||||||
|
test/...
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждая строка содержит `messages` (system/user/assistant), `record_id`,
|
||||||
|
`scenario_id`, `mode`, `split`, `source`, `validation_level`.
|
||||||
|
В `000000.jsonl` ответ `assistant.content` — обычный русский текст:
|
||||||
|
|
||||||
|
- **full** — нумерованная последовательность действий.
|
||||||
|
- **step** — одна русская фраза с названием объекта. Вход содержит состояние
|
||||||
|
перед действием и историю выполненных действий.
|
||||||
|
- **branch** — нумерованный план с условиями «если выполнено успешно» и «если
|
||||||
|
произошла ошибка». После ошибки требуется уточнить сцену и перепланировать.
|
||||||
|
|
||||||
|
В `000000.structured.jsonl` поле `assistant.content` содержит JSON в виде строки
|
||||||
|
(стандартный формат `messages` для обучения):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"ready","plan":[{"skill":"pick","object":"деталь №1"},{"skill":"align","object":"деталь №1","target":"сборочная позиция №1"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Это фрагмент примера; полный план включает остальные действия. Названия объектов
|
||||||
|
и мест берутся из сцены. `target` есть у действий с местом назначения; у `pick`,
|
||||||
|
`release`, `inspect` его нет. Навыки остаются на английском. `return_home` относится
|
||||||
|
к объекту «робот» с целью «исходное положение».
|
||||||
|
|
||||||
|
В `step` массив `plan` содержит одно следующее действие со статусом `ready`.
|
||||||
|
Без доступных действий он пуст; статус — `ready` при достигнутой цели или
|
||||||
|
`blocked` при блокировке. В `full` и `branch` статус характеризует весь сценарий;
|
||||||
|
при `blocked` массив может содержать выполнимую часть плана.
|
||||||
|
|
||||||
|
В `branch` у каждого действия дополнительно есть `id`, `on_success`, `on_failure`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id":"step_1","skill":"pick","object":"деталь №1","on_success":"step_2","on_failure":"replan_required"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Первым выполняется `step_1`. Успех ведёт к следующему ID, а у последнего действия —
|
||||||
|
к терминальному исходу `ready` или `blocked`. Ошибка ведёт к `replan_required`:
|
||||||
|
остановиться, обновить сцену и построить новый план. Для пустого плана итог задан
|
||||||
|
полем `status`.
|
||||||
|
|
||||||
|
Две версии имеют одинаковые ID, порядок, разбиение и входные сцены, но разные
|
||||||
|
системные инструкции. Для обучения одному формату выбирайте только соответствующие
|
||||||
|
файлы; совместное обучение двум форматам требует сохранять их системные инструкции.
|
||||||
|
|
||||||
|
Пример для помощи оператору:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. Определить положение объекта «распределительная коробка №1».
|
||||||
|
2. Переместиться к объекту «распределительная коробка №1».
|
||||||
|
3. Захватить объект «распределительная коробка №1».
|
||||||
|
4. Переместить объект «распределительная коробка №1» к оператору.
|
||||||
|
5. Удерживать объект «распределительная коробка №1» в неподвижном состоянии возле оператора.
|
||||||
|
6. Дождаться подтверждения завершения сборки объекта «распределительная коробка №1», продолжая удерживать его.
|
||||||
|
7. Переместить объект «распределительная коробка №1» в место «зона хранения №1».
|
||||||
|
8. Отпустить объект «распределительная коробка №1».
|
||||||
|
9. Вернуться в исходное положение.
|
||||||
|
```
|
||||||
|
|
||||||
|
Домен `operator_assistance` описывает удержание объекта у оператора. `collaborative`
|
||||||
|
описывает передачу объекта оператору: это другая задача, с другим порядком отпускания.
|
||||||
|
Чтобы получить только сценарии удержания, задайте `domains: [operator_assistance]`.
|
||||||
|
|
||||||
|
Технические навыки, предусловия, эффекты и графы сохраняются внутри канонического
|
||||||
|
эпизода для проверки. Обучаемая модель получает сцену с русскими названиями объектов,
|
||||||
|
их положением, доступностью и зависимостями. Статусы находятся в метаданных записи;
|
||||||
|
при невыполнимости текст ответа явно сообщает о блокировке.
|
||||||
|
|
||||||
|
Версия экспорта — 3, версия хранилища эпизодов — 2. Сохранённые эпизоды версии 2
|
||||||
|
можно переэкспортировать в оба варианта без запросов к LLM, сохранив настройки
|
||||||
|
генерации. Корпуса версии 1 несовместимы:
|
||||||
|
используйте новую папку. В конфигурации задана `dataset/assembly-ru`, чтобы не смешивать
|
||||||
|
русские ответы с прежним техническим JSON.
|
||||||
|
|
||||||
|
SQLite служит транзакционным checkpoint и индексом уникальности. Он хранит полные
|
||||||
|
эпизоды, включая trace и teacher-ошибки. Пакет в памяти ограничен размером очереди;
|
||||||
|
SFT экспортируется потоково по шардам. После прерывания повторите команду:
|
||||||
|
принятые эпизоды сохраняются, незавершённый пакет повторяется. При teacher-режиме
|
||||||
|
запросы незавершённого пакета могут повториться. Одновременные писатели в одну папку
|
||||||
|
блокируются. Неизменённый процедурный запуск воспроизводим побайтно.
|
||||||
|
|
||||||
|
Полный экспорт пересобирается во временную папку и затем заменяет предыдущий.
|
||||||
|
На время переэкспорта нужно место для обеих копий. Старый экспорт до завершения
|
||||||
|
нового остаётся доступен. SQLite и SFT для 500 тысяч строк могут занимать десятки
|
||||||
|
гигабайт: размер зависит от числа компонентов, режимов и `step_sampling`.
|
||||||
|
|
||||||
|
## CSV для просмотра
|
||||||
|
|
||||||
|
Рядом с каждым шардом JSONL автоматически создаётся одноимённый CSV:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sft/train/full/000000.jsonl
|
||||||
|
sft/train/full/000000.csv
|
||||||
|
sft/train/full/000000.structured.jsonl
|
||||||
|
sft/train/full/000000.structured.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Одна строка CSV соответствует одной обучающей записи. Первые колонки:
|
||||||
|
«Задание», «Сцена», «План», «Статус», «Формат», «Источник текста».
|
||||||
|
В колонке «План» находится содержимое `assistant.content`: русский текст в обычном
|
||||||
|
CSV и JSON в `.structured.csv`.
|
||||||
|
Метаданные и полный вход находятся после основных колонок.
|
||||||
|
|
||||||
|
Кодировка — UTF-8 с BOM, разделитель — точка с запятой (`;`). При импорте в
|
||||||
|
Excel или LibreOffice выберите этот разделитель и включите перенос текста
|
||||||
|
в ячейках. Поля с кавычками, разделителями и переносами строк экранируются.
|
||||||
|
|
||||||
|
CSV включены в контрольные суммы manifest и проверяются командой `validate`.
|
||||||
|
CSV не увеличивают `examples`: это представления тех же записей.
|
||||||
|
Для добавления CSV к существующему корпусу выполните `export` с нужным лимитом;
|
||||||
|
запросов к LLM при этом нет.
|
||||||
|
|
||||||
|
## Продолжение и переэкспорт
|
||||||
|
|
||||||
|
После остановки повторите команду `generate` с тем же конфигом и папкой результатов.
|
||||||
|
Для изменения количества строк используйте `--count`. Меньший лимит уменьшает
|
||||||
|
экспорт, но сохраняет канонические эпизоды в SQLite.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Повторно экспортировать 100 записей из сохранённых эпизодов
|
||||||
|
uv run python -m assembly_dataset export --count 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Без `--count` команда `export` использует лимит из YAML. Если столько записей ещё
|
||||||
|
не подготовлено, сначала выполните `generate` или укажите меньший лимит.
|
||||||
|
|
||||||
|
## Модель мира и ограничения
|
||||||
|
|
||||||
|
В `world.py` находятся одиннадцать расширяемых рецептов: перемещение, механосборка,
|
||||||
|
крепёж, прессовая посадка, электромонтаж, электроника, клей, сварка, совместная
|
||||||
|
сборка, удержание объекта у оператора и контроль. Сцены различаются количеством компонентов, начальными
|
||||||
|
положениями, уже завершёнными стадиями, отсутствующими деталями и DAG-зависимостями.
|
||||||
|
Текущее положение задают предикаты `at`/`holding` в состоянии; поле
|
||||||
|
`components[].source_location` обозначает исходное место подачи детали.
|
||||||
|
Это каталог базовых абстрактных процессов, **не исчерпывающая модель всей промышленности**.
|
||||||
|
Новый процесс требует рецепта, корректных предусловий/эффектов и тестов.
|
||||||
|
|
||||||
|
У каждой операции есть `requires`, `forbids`, `add`, `delete`. Полный план
|
||||||
|
проверяется повторным исполнением; ветвящийся — обходом всех доступных путей.
|
||||||
|
Состояния и эффекты задаёт генератор, teacher не может их переписать.
|
||||||
|
`blocked` проверяется эталонным решателем для этого ограниченного ациклического
|
||||||
|
домена. Это не универсальный PDDL-планировщик.
|
||||||
|
|
||||||
|
Структурная сигнатура строится из сцены с фиксированными каноническими ID.
|
||||||
|
Номер кандидата, формулировка задания и ответ LLM не увеличивают разнообразие.
|
||||||
|
Дедупликация выполняется уникальным индексом SQLite; при исчерпании малого
|
||||||
|
пространства параметров генератор завершится с ошибкой. Варианты одного сценария
|
||||||
|
получают один split по сигнатуре. Родственные, но различные сцены могут попасть
|
||||||
|
в разные split — это не строгий тест переноса на неизвестные семейства изделий.
|
||||||
|
Для маленьких корпусов присутствие validation/test не гарантируется.
|
||||||
|
|
||||||
|
`validation_level: logical_only`: нет проверки геометрии, столкновений, усилий,
|
||||||
|
качества сварки/клея и реальной выполнимости навыков. VLM/VLA и разметка изображений
|
||||||
|
не поддерживаются.
|
||||||
|
|
||||||
|
## Teacher-модели
|
||||||
|
|
||||||
|
В поставляемом `config.yaml` включён Codex для всех сцен (`enabled: true`,
|
||||||
|
`fraction: 1.0`). При `enabled: false` планы строятся локально. Для подключения
|
||||||
|
OpenAI-совместимого сервера настройте блок:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
teacher:
|
||||||
|
enabled: true
|
||||||
|
fraction: 0.15 # доля сцен; 1.0 — все сцены
|
||||||
|
concurrency: 4
|
||||||
|
timeout_seconds: 180
|
||||||
|
retries: 2
|
||||||
|
llm:
|
||||||
|
provider: openai
|
||||||
|
model: your-provider/your-model
|
||||||
|
api_key_env: API_KEY
|
||||||
|
base_url_env: BASE_URL
|
||||||
|
generation:
|
||||||
|
temperature: null
|
||||||
|
max_tokens: 8192
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключ и адрес сервера берутся из окружения или корневого `.env`.
|
||||||
|
Для Codex замените `llm`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
llm:
|
||||||
|
provider: codex
|
||||||
|
model: <доступная вам модель>
|
||||||
|
credential_path: .config/codex-credentials.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Подключение выполняет `llm.factory.LLMModelProvider`. Teacher возвращает полный
|
||||||
|
план: для каждого действия — внутренние `skill/object/target` и русское описание
|
||||||
|
`text`. Код проверяет последовательность внутренних действий и сохраняет текст
|
||||||
|
модели без замены шаблонной формулировкой. Затем из полного плана создаются
|
||||||
|
пошаговые записи и условия ветвления; сами условия ошибки формирует код.
|
||||||
|
|
||||||
|
Логическая проверка подтверждает внутренние действия. Для текста проверяются
|
||||||
|
наличие русского описания, количество шагов, названия объектов и отсутствие
|
||||||
|
технических ID. Текст также должен совпадать с `action_templates` после нормализации
|
||||||
|
пунктуации и ограниченного набора синонимов. Допускаются «Сначала/Затем/Далее»,
|
||||||
|
«взять/захватить», «перенести/переместить», «проконтролировать/проверить»,
|
||||||
|
«возвратиться/вернуться», «местоположение/положение». Другие формулировки отклоняются
|
||||||
|
и запрашиваются заново: свободный текст не считается доказательством корректности.
|
||||||
|
Для изменения основного стиля редактируйте `action_templates` в `prompts.yaml`.
|
||||||
|
|
||||||
|
После неудачных попыток используется символьный план и русские шаблоны из
|
||||||
|
`action_templates`. Это помечается как `source: symbolic`, `text_source: template`
|
||||||
|
и `teacher_error`. У успешного ответа модели `text_source: teacher`.
|
||||||
|
В CSV это видно в колонке «Источник текста». Смена `action_templates` не
|
||||||
|
переписывает сохранённые формулировки teacher.
|
||||||
|
|
||||||
|
Реальные API-вызовы не входят в локальные тесты. Для сравнения моделей используйте
|
||||||
|
отдельные папки и проверяйте `sources` и `teacher_failures` в manifest.
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```text
|
||||||
|
assembly_dataset/ # генерация сцен, валидация, teacher, хранение и CLI
|
||||||
|
config.yaml # модель, размер, сцены и параметры генерации
|
||||||
|
prompts.yaml # инструкции LLM и тексты обучающих записей
|
||||||
|
llm/ # провайдеры моделей, авторизация и общие схемы настроек
|
||||||
|
tests/ # тесты генератора и конвейера
|
||||||
|
```
|
||||||
|
|
||||||
|
Промпты редактируются в [prompts.yaml](prompts.yaml), без изменения Python-кода.
|
||||||
|
Параметр `prompts_file` в `config.yaml` позволяет выбрать другой файл промптов.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m unittest discover -s tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Тесты используют локальную генерацию и тестовый агент без внешних API-запросов.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Reproducible, logically validated assembly planning datasets."""
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .config import load_config
|
||||||
|
from .pipeline import generate, export, validate
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Датасет высокоуровневых планов сборки: full / step / branch')
|
||||||
|
parser.add_argument('--config', type=Path, default=Path(__file__).resolve().parents[1] / 'config.yaml')
|
||||||
|
parser.add_argument('command', choices=['generate', 'export', 'validate'], nargs='?', default='generate')
|
||||||
|
parser.add_argument('--count', type=int, help='Точное суммарное число SFT-записей; 0 допустим')
|
||||||
|
parser.add_argument('--output-dir', type=Path, help='Папка корпуса (относительно текущего каталога)')
|
||||||
|
parser.add_argument('--dry-run', action='store_true', help='Показать настройки без записи и запросов')
|
||||||
|
parser.add_argument('--no-progress', action='store_true', help='Отключить индикаторы прогресса')
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
config = load_config(args.config)
|
||||||
|
data = config.model_dump()
|
||||||
|
if args.count is not None:
|
||||||
|
data['target_examples'] = args.count
|
||||||
|
if args.output_dir is not None:
|
||||||
|
data['output_dir'] = args.output_dir.resolve()
|
||||||
|
config = type(config).model_validate(data)
|
||||||
|
if args.dry_run:
|
||||||
|
print(config.model_dump_json(indent=2))
|
||||||
|
return 0
|
||||||
|
if args.command == 'generate':
|
||||||
|
if config.teacher.enabled:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(Path(__file__).resolve().parents[1] / '.env')
|
||||||
|
asyncio.run(generate(config, show_progress=not args.no_progress))
|
||||||
|
result = export(config, show_progress=not args.no_progress)
|
||||||
|
result = {k: result[k] for k in ('examples', 'scenarios', 'counts', 'teacher_failures')}
|
||||||
|
elif args.command == 'export':
|
||||||
|
result = export(config, show_progress=not args.no_progress)
|
||||||
|
result = {k: result[k] for k in ('examples', 'scenarios', 'counts', 'teacher_failures')}
|
||||||
|
else:
|
||||||
|
result = validate(config)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
except (ValueError, OSError) as exc:
|
||||||
|
print(f'Ошибка: {exc}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import Field, model_validator
|
||||||
|
|
||||||
|
from llm.config import ConfigModel, LLMConfig, GenerationConfig, CodexConfig
|
||||||
|
|
||||||
|
from .prompts import Prompts, load_prompts
|
||||||
|
|
||||||
|
DOMAINS = (
|
||||||
|
'manipulation', 'mechanical', 'fastening', 'press_fit', 'electrical',
|
||||||
|
'electronics', 'adhesive', 'welding', 'collaborative', 'inspection', 'operator_assistance',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TeacherConfig(ConfigModel):
|
||||||
|
enabled: bool = False
|
||||||
|
fraction: float = Field(default=0.15, ge=0, le=1)
|
||||||
|
llm: LLMConfig | None = None
|
||||||
|
generation: GenerationConfig = Field(default_factory=GenerationConfig)
|
||||||
|
concurrency: int = Field(default=4, ge=1, le=128)
|
||||||
|
timeout_seconds: float = Field(default=180, gt=0)
|
||||||
|
retries: int = Field(default=2, ge=0, le=10)
|
||||||
|
|
||||||
|
@model_validator(mode='after')
|
||||||
|
def require_model(self):
|
||||||
|
if self.enabled and self.llm is None:
|
||||||
|
raise ValueError('teacher.llm обязателен при teacher.enabled: true')
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class Config(ConfigModel):
|
||||||
|
# Count means exported SFT rows, not episodes or API calls.
|
||||||
|
target_examples: int = Field(default=500_000, ge=0, strict=True)
|
||||||
|
seed: int = 20261021
|
||||||
|
output_dir: Path = Path('dataset/assembly')
|
||||||
|
modes: list[Literal['full', 'step', 'branch']] = Field(default_factory=lambda: ['full', 'step', 'branch'], min_length=1)
|
||||||
|
step_sampling: Literal['one', 'all'] = 'one'
|
||||||
|
shard_size: int = Field(default=1000, ge=1)
|
||||||
|
split: tuple[float, float, float] = (0.9, 0.05, 0.05)
|
||||||
|
domains: list[str] = Field(default_factory=lambda: list(DOMAINS), min_length=1)
|
||||||
|
min_components: int = Field(default=2, ge=1, le=12)
|
||||||
|
max_components: int = Field(default=6, ge=1, le=12)
|
||||||
|
blocked_fraction: float = Field(default=0.1, ge=0, le=1)
|
||||||
|
prompts_file: Path = Path("prompts.yaml")
|
||||||
|
prompts: Prompts = Field(default_factory=load_prompts)
|
||||||
|
teacher: TeacherConfig = Field(default_factory=TeacherConfig)
|
||||||
|
|
||||||
|
@model_validator(mode='after')
|
||||||
|
def check(self):
|
||||||
|
if len(set(self.modes)) != len(self.modes):
|
||||||
|
raise ValueError('modes не должны повторяться')
|
||||||
|
if len(set(self.domains)) != len(self.domains) or set(self.domains) - set(DOMAINS):
|
||||||
|
raise ValueError(f'Допустимые domains: {DOMAINS}')
|
||||||
|
if set(self.domains) - set(self.prompts.task_instructions):
|
||||||
|
raise ValueError("В task_instructions отсутствуют инструкции выбранных domains")
|
||||||
|
if self.min_components > self.max_components:
|
||||||
|
raise ValueError('min_components > max_components')
|
||||||
|
if any(v < 0 or v > 1 for v in self.split) or abs(sum(self.split) - 1) > 1e-9:
|
||||||
|
raise ValueError('split должен содержать три доли с суммой 1')
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> Config:
|
||||||
|
path = path.resolve()
|
||||||
|
data = yaml.safe_load(path.read_text(encoding='utf-8')) or {}
|
||||||
|
prompts_path = (path.parent / data.get('prompts_file', 'prompts.yaml')).resolve()
|
||||||
|
data['prompts'] = load_prompts(prompts_path)
|
||||||
|
data['prompts_file'] = prompts_path
|
||||||
|
config = Config.model_validate(data)
|
||||||
|
config.output_dir = (path.parent / config.output_dir).resolve()
|
||||||
|
llm = config.teacher.llm
|
||||||
|
if isinstance(llm, CodexConfig) and llm.credential_path:
|
||||||
|
llm.credential_path = (path.parent / llm.credential_path).resolve()
|
||||||
|
return config
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Readable Russian plans with metadata kept after the primary columns."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
COLUMNS = [
|
||||||
|
'Задание', 'Сцена', 'План', 'Статус', 'Формат', 'Источник текста',
|
||||||
|
'ID записи', 'ID сценария', 'Выборка', 'Вход (задание и сцена)',
|
||||||
|
'Системная инструкция', 'Провайдер', 'Модель', 'Уровень проверки',
|
||||||
|
]
|
||||||
|
STATUSES = {'ready': 'План готов', 'blocked': 'Задача невыполнима', 'continue': 'Следующее действие'}
|
||||||
|
MODES = {'full': 'Полный план', 'step': 'Следующее действие', 'branch': 'План с условиями'}
|
||||||
|
|
||||||
|
|
||||||
|
def csv_record(record):
|
||||||
|
messages = {m['role']: m['content'] for m in record['messages']}
|
||||||
|
user = messages['user']
|
||||||
|
try:
|
||||||
|
user = json.dumps(json.loads(user), ensure_ascii=False, indent=2)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
'Задание': record['task'], 'Сцена': record['scene_summary'],
|
||||||
|
'План': messages['assistant'], 'Статус': STATUSES[record['status']],
|
||||||
|
'Формат': MODES[record['mode']],
|
||||||
|
'Источник текста': 'LLM' if record['text_source'] == 'teacher' else 'Шаблон русского описания',
|
||||||
|
'ID записи': record['record_id'], 'ID сценария': record['scenario_id'],
|
||||||
|
'Выборка': record['split'], 'Вход (задание и сцена)': user,
|
||||||
|
'Системная инструкция': messages['system'],
|
||||||
|
'Провайдер': record['source']['provider'], 'Модель': record['source']['model'] or '',
|
||||||
|
'Уровень проверки': record['validation_level'],
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Russian action descriptions and readable scenes for the planning model."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
LOCATIONS = {'tray': 'лоток', 'buffer': 'буферная зона', 'conveyor': 'конвейер'}
|
||||||
|
SIZES = {'small': 'маленький', 'medium': 'средний', 'large': 'большой'}
|
||||||
|
|
||||||
|
|
||||||
|
def component_for(action, scene):
|
||||||
|
return next(c for c in scene['components'] if c['id'] == action['object'])
|
||||||
|
|
||||||
|
|
||||||
|
def object_name(component, scene, prompts):
|
||||||
|
return component.get('name') or f"{prompts.object_names.get(scene['domain'], 'деталь')} №{scene['components'].index(component) + 1}"
|
||||||
|
|
||||||
|
|
||||||
|
def target_name(component, scene):
|
||||||
|
return component.get('target_name') or f"сборочная позиция №{scene['components'].index(component) + 1}"
|
||||||
|
|
||||||
|
|
||||||
|
def action_text(action, scene, prompts):
|
||||||
|
component = component_for(action, scene)
|
||||||
|
return prompts.action_templates[action['skill']].format(
|
||||||
|
object=object_name(component, scene, prompts), target=target_name(component, scene))
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_action_text(text):
|
||||||
|
words = re.findall(r'[\w]+', text.casefold())
|
||||||
|
while words and words[0] in {'сначала', 'затем', 'далее'}:
|
||||||
|
words.pop(0)
|
||||||
|
synonyms = {'перенести': 'переместить', 'взять': 'захватить',
|
||||||
|
'проконтролировать': 'проверить', 'возвратиться': 'вернуться',
|
||||||
|
'местоположение': 'положение'}
|
||||||
|
return [synonyms.get(word, word) for word in words]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_texts(episode, texts, prompts):
|
||||||
|
if len(texts) != len(episode['plan']['actions']):
|
||||||
|
raise ValueError('Число русских описаний не совпадает с числом действий')
|
||||||
|
for action, text in zip(episode['plan']['actions'], texts):
|
||||||
|
if not text.strip() or not re.search('[А-Яа-яЁё]', text):
|
||||||
|
raise ValueError('Каждое действие должно быть описано на русском языке')
|
||||||
|
if re.search(r'\b(?:part_|slot_)\d+|\b(?:robot|camera)\.', text):
|
||||||
|
raise ValueError('В тексте плана используй названия объектов, а не код')
|
||||||
|
component = component_for(action, episode['scene'])
|
||||||
|
if action['skill'] != 'return_home' and component.get('name') and component['name'].casefold() not in text.casefold():
|
||||||
|
raise ValueError(f"В описании действия укажи название объекта: {component['name']}")
|
||||||
|
expected = action_text(action, episode['scene'], prompts)
|
||||||
|
if normalized_action_text(text) != normalized_action_text(expected):
|
||||||
|
raise ValueError(f'Описание не соответствует проверяемому действию. Используй: {expected}')
|
||||||
|
|
||||||
|
|
||||||
|
def step_texts(episode, prompts):
|
||||||
|
if 'step_texts' in episode:
|
||||||
|
validate_texts(episode, episode['step_texts'], prompts)
|
||||||
|
return episode['step_texts']
|
||||||
|
return [action_text(action, episode['scene'], prompts) for action in episode['plan']['actions']]
|
||||||
|
|
||||||
|
|
||||||
|
def terminal_text(status):
|
||||||
|
return ('Задание выполнено.' if status == 'ready' else
|
||||||
|
'Задание невозможно завершить в текущих условиях. Сообщить оператору о недоступных ресурсах или зависимостях и запросить уточнение.')
|
||||||
|
|
||||||
|
|
||||||
|
def answer_text(episode, mode, index, prompts):
|
||||||
|
steps = step_texts(episode, prompts)
|
||||||
|
status = episode['plan']['status']
|
||||||
|
if mode == 'step':
|
||||||
|
return steps[index] if steps else terminal_text(status)
|
||||||
|
if not steps:
|
||||||
|
return terminal_text(status)
|
||||||
|
if mode == 'full':
|
||||||
|
lines = [f'{i}. {text}' for i, text in enumerate(steps, 1)]
|
||||||
|
if status == 'blocked':
|
||||||
|
lines.append(terminal_text(status))
|
||||||
|
return '\n'.join(lines)
|
||||||
|
lines = []
|
||||||
|
for i, text in enumerate(steps, 1):
|
||||||
|
success = f'перейти к пункту {i + 1}' if i < len(steps) else terminal_text(status)
|
||||||
|
lines.append(f'{i}. {text}\n Если действие выполнено успешно — {success}.\n'
|
||||||
|
' Если произошла ошибка — остановиться, уточнить состояние сцены и запросить новый план.')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def readable_scene(scene, state, prompts):
|
||||||
|
state = set(state)
|
||||||
|
names = {c['id']: object_name(c, scene, prompts) for c in scene['components']}
|
||||||
|
objects = []
|
||||||
|
for c in scene['components']:
|
||||||
|
prefix = f"at:{c['id']}:"
|
||||||
|
location = next((s[len(prefix):] for s in state if s.startswith(prefix)), None)
|
||||||
|
location = target_name(c, scene) if location == c['target'] else LOCATIONS.get(location, location)
|
||||||
|
if f"holding:{c['id']}" in state:
|
||||||
|
location = 'в захвате робота'
|
||||||
|
stage = next((int(s.rsplit(':', 1)[1]) for s in state if s.startswith(f"{c['id']}:stage:")), 0)
|
||||||
|
operations = [op['action'] for op in scene['operations'] if op['action']['object'] == c['id']]
|
||||||
|
objects.append({'название': names[c['id']], 'размер': SIZES.get(c['type'], c['type']),
|
||||||
|
'положение': location or 'не определено', 'назначение': target_name(c, scene),
|
||||||
|
'доступен': f"available:{c['id']}" in state,
|
||||||
|
'после_завершения': [names[d] for d in c['dependencies']],
|
||||||
|
'выполненные_действия': [action_text(a, scene, prompts) for a in operations[:stage]]})
|
||||||
|
return {'объекты': objects, 'захват_свободен': 'gripper_empty' in state,
|
||||||
|
'оператор_готов': 'operator_ready' in state,
|
||||||
|
'цель': 'Выполнить задание для всех перечисленных объектов и освободить захват.',
|
||||||
|
'ограничение': 'Соблюдать зависимости объектов и не начинать следующий этап до завершения предыдущего.'}
|
||||||
|
|
||||||
|
|
||||||
|
def scene_summary(scene):
|
||||||
|
lines = []
|
||||||
|
for obj in scene['объекты']:
|
||||||
|
lines.append(f"{obj['название']}: {obj['положение']}; назначение — {obj['назначение']}; "
|
||||||
|
f"{'доступен' if obj['доступен'] else 'недоступен'}.")
|
||||||
|
if obj['после_завершения']:
|
||||||
|
lines.append(' Начать после: ' + ', '.join(obj['после_завершения']) + '.')
|
||||||
|
return '\n'.join(lines)
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""Bounded generation, transactional checkpoints, streaming sharded export."""
|
||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
|
from collections import Counter
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from itertools import zip_longest
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .language import validate_texts
|
||||||
|
from .csv_export import COLUMNS, csv_record
|
||||||
|
from .progress import ProgressDisplay
|
||||||
|
from .world import canonical, digest, make_episode, training_rows, validate_plan, validate_branch
|
||||||
|
|
||||||
|
VERSION = 3
|
||||||
|
STORAGE_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def storage(config):
|
||||||
|
root = config.output_dir
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
with (root / '.lock').open('a') as lock:
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
raise ValueError('Другой процесс уже работает с output_dir') from None
|
||||||
|
db = sqlite3.connect(root / 'episodes.sqlite3')
|
||||||
|
try:
|
||||||
|
db.execute('PRAGMA journal_mode=WAL')
|
||||||
|
db.execute('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)')
|
||||||
|
db.execute('CREATE TABLE IF NOT EXISTS episodes (seq INTEGER PRIMARY KEY, signature TEXT UNIQUE NOT NULL, payload TEXT NOT NULL, rows INTEGER NOT NULL)')
|
||||||
|
settings = config.model_dump(mode='json', exclude={'target_examples', 'output_dir', 'shard_size', 'prompts_file'})
|
||||||
|
# Structured prompts only affect export, so existing v2 episodes remain reusable.
|
||||||
|
settings['prompts'].pop('structured_system', None)
|
||||||
|
fingerprint = digest({'version': STORAGE_VERSION, 'settings': settings})
|
||||||
|
saved = db.execute("SELECT value FROM meta WHERE key='fingerprint'").fetchone()
|
||||||
|
if saved and saved[0] != fingerprint:
|
||||||
|
raise ValueError('Изменён config: для других настроек используйте новый output_dir')
|
||||||
|
if not saved:
|
||||||
|
with db:
|
||||||
|
db.execute('INSERT INTO meta VALUES (?, ?)', ('fingerprint', fingerprint))
|
||||||
|
db.execute('INSERT INTO meta VALUES (?, ?)', ('settings', canonical(settings)))
|
||||||
|
db.execute('INSERT INTO meta VALUES (?, ?)', ('next_index', '0'))
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def generate(config: Config, agent=None, *, show_progress=False):
|
||||||
|
with storage(config) as db:
|
||||||
|
count = db.execute('SELECT COALESCE(SUM(rows), 0) FROM episodes').fetchone()[0]
|
||||||
|
index = int(db.execute("SELECT value FROM meta WHERE key='next_index'").fetchone()[0])
|
||||||
|
with ProgressDisplay('Генерация', config.target_examples, initial=count, enabled=show_progress) as progress:
|
||||||
|
if count >= config.target_examples:
|
||||||
|
return count
|
||||||
|
if config.teacher.enabled and config.teacher.fraction > 0 and agent is None:
|
||||||
|
from .teacher import create_agent
|
||||||
|
progress.update(status='подключение LLM')
|
||||||
|
agent = await create_agent(config.teacher, config.prompts)
|
||||||
|
batch_size = config.teacher.concurrency if agent is not None else 32
|
||||||
|
duplicates = 0
|
||||||
|
while count < config.target_examples:
|
||||||
|
# Never construct 500k coroutines or keep the entire dataset in RAM.
|
||||||
|
size = min(batch_size, max(1, config.target_examples - count))
|
||||||
|
episodes = [make_episode(i, config) for i in range(index, index + size)]
|
||||||
|
if agent is not None:
|
||||||
|
from .teacher import enrich
|
||||||
|
progress.update(status='ожидание LLM')
|
||||||
|
episodes = [item[0] for item in await asyncio.gather(*(
|
||||||
|
enrich(e, config.teacher, agent, config.prompts) for e in episodes))]
|
||||||
|
with db:
|
||||||
|
for episode in episodes:
|
||||||
|
rows = sum(1 for _ in training_rows(episode, config))
|
||||||
|
cursor = db.execute('INSERT OR IGNORE INTO episodes(signature,payload,rows) VALUES (?,?,?)',
|
||||||
|
(episode['signature'], canonical(episode), rows))
|
||||||
|
if cursor.rowcount:
|
||||||
|
count += rows
|
||||||
|
duplicates = 0
|
||||||
|
else:
|
||||||
|
duplicates += 1
|
||||||
|
index = episode['candidate_index'] + 1
|
||||||
|
if count >= config.target_examples:
|
||||||
|
break
|
||||||
|
db.execute("UPDATE meta SET value=? WHERE key='next_index'", (str(index),))
|
||||||
|
if duplicates > 10000:
|
||||||
|
raise ValueError('Пространство сценариев исчерпано: расширьте диапазон компонентов или domains')
|
||||||
|
progress.update(count, status='')
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
class Shards:
|
||||||
|
def __init__(self, root, size):
|
||||||
|
self.root, self.size = root, size
|
||||||
|
self.counts = Counter()
|
||||||
|
self.handles = {}
|
||||||
|
self.csv_handles = {}
|
||||||
|
self.csv_writers = {}
|
||||||
|
self.files = []
|
||||||
|
|
||||||
|
def write(self, row, structured_row):
|
||||||
|
key = row['split'], row['mode']
|
||||||
|
count = self.counts[key]
|
||||||
|
for variant, record in (('text', row), ('structured', structured_row)):
|
||||||
|
handle_key = (*key, variant)
|
||||||
|
if count % self.size == 0:
|
||||||
|
if handle_key in self.handles:
|
||||||
|
self.handles[handle_key].close()
|
||||||
|
self.csv_handles[handle_key].close()
|
||||||
|
suffix = '.jsonl' if variant == 'text' else '.structured.jsonl'
|
||||||
|
path = self.root / key[0] / key[1] / f'{count // self.size:06d}{suffix}'
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.handles[handle_key] = path.open('w', encoding='utf-8')
|
||||||
|
self.files.append(path)
|
||||||
|
csv_path = path.with_suffix('.csv')
|
||||||
|
self.csv_handles[handle_key] = csv_path.open('w', encoding='utf-8-sig', newline='')
|
||||||
|
self.csv_writers[handle_key] = csv.DictWriter(self.csv_handles[handle_key], fieldnames=COLUMNS, delimiter=';')
|
||||||
|
self.csv_writers[handle_key].writeheader()
|
||||||
|
self.files.append(csv_path)
|
||||||
|
self.handles[handle_key].write(canonical(record) + '\n')
|
||||||
|
self.csv_writers[handle_key].writerow(csv_record(record))
|
||||||
|
self.counts[key] += 1
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
for file in (*self.handles.values(), *self.csv_handles.values()):
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
|
||||||
|
def file_digest(path):
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
with path.open('rb') as file:
|
||||||
|
for block in iter(lambda: file.read(1024 * 1024), b''):
|
||||||
|
hasher.update(block)
|
||||||
|
return hasher.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def export(config, *, show_progress=False):
|
||||||
|
with storage(config) as db, ProgressDisplay('Экспорт', config.target_examples, enabled=show_progress) as progress:
|
||||||
|
capacity = db.execute('SELECT COALESCE(SUM(rows), 0) FROM episodes').fetchone()[0]
|
||||||
|
if capacity < config.target_examples:
|
||||||
|
raise ValueError('Недостаточно сценариев: сначала выполните generate')
|
||||||
|
destination = config.output_dir / 'sft'
|
||||||
|
backup = config.output_dir / '.sft-previous'
|
||||||
|
# Recover an interrupted directory swap before making another export.
|
||||||
|
if backup.exists() and not destination.exists():
|
||||||
|
backup.rename(destination)
|
||||||
|
if destination.exists() and not (destination / 'manifest.json').exists():
|
||||||
|
raise ValueError('sft не является управляемым экспортом; выберите другой output_dir')
|
||||||
|
stage = Path(tempfile.mkdtemp(prefix='.sft-', dir=config.output_dir))
|
||||||
|
writer = Shards(stage, config.shard_size)
|
||||||
|
total, scenarios, failures = 0, 0, 0
|
||||||
|
domains, sources = Counter(), Counter()
|
||||||
|
try:
|
||||||
|
for (payload,) in db.execute('SELECT payload FROM episodes ORDER BY seq'):
|
||||||
|
if total >= config.target_examples:
|
||||||
|
break
|
||||||
|
episode = json.loads(payload)
|
||||||
|
scenarios += 1
|
||||||
|
domains[episode['scene']['domain']] += 1
|
||||||
|
sources[episode['source']['provider']] += 1
|
||||||
|
failures += int('teacher_error' in episode)
|
||||||
|
for record, structured_record in zip(training_rows(episode, config),
|
||||||
|
training_rows(episode, config, output_format='structured'), strict=True):
|
||||||
|
if total >= config.target_examples:
|
||||||
|
break
|
||||||
|
writer.write(record, structured_record)
|
||||||
|
total += 1
|
||||||
|
progress.update(total)
|
||||||
|
progress.update(status="контрольные суммы")
|
||||||
|
writer.close()
|
||||||
|
manifest = {'schema_version': VERSION, 'examples': total, 'scenarios': scenarios,
|
||||||
|
'examples_per_variant': {'text': total, 'structured': total},
|
||||||
|
'counts': {f'{split}/{mode}': n for (split, mode), n in sorted(writer.counts.items())},
|
||||||
|
'domains': dict(domains), 'sources': dict(sources), 'teacher_failures': failures,
|
||||||
|
'validation_level': 'logical_only',
|
||||||
|
'config': config.model_dump(mode='json'),
|
||||||
|
'files': {str(p.relative_to(stage)): file_digest(p) for p in writer.files}}
|
||||||
|
(stage / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
if backup.exists():
|
||||||
|
shutil.rmtree(backup)
|
||||||
|
if destination.exists():
|
||||||
|
destination.rename(backup)
|
||||||
|
stage.rename(destination)
|
||||||
|
if backup.exists():
|
||||||
|
shutil.rmtree(backup)
|
||||||
|
progress.update(total, status='')
|
||||||
|
return manifest
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
if stage.exists():
|
||||||
|
shutil.rmtree(stage)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(config):
|
||||||
|
with storage(config) as db:
|
||||||
|
episodes, paths = 0, 0
|
||||||
|
for signature, payload, stored_rows in db.execute('SELECT signature,payload,rows FROM episodes'):
|
||||||
|
episode = json.loads(payload)
|
||||||
|
if signature != digest(episode['scene']) or episode['signature'] != signature or episode['scenario_id'] != signature:
|
||||||
|
raise ValueError('Неверная сигнатура сценария')
|
||||||
|
if 'step_texts' in episode:
|
||||||
|
validate_texts(episode, episode['step_texts'], config.prompts)
|
||||||
|
trace = validate_plan(episode, episode['plan'])
|
||||||
|
if trace != episode['trace']:
|
||||||
|
raise ValueError('Trace не соответствует плану')
|
||||||
|
paths += validate_branch(episode, episode['branch'])
|
||||||
|
if sum(1 for _ in training_rows(episode, config)) != stored_rows:
|
||||||
|
raise ValueError('Неверное число записей эпизода')
|
||||||
|
episodes += 1
|
||||||
|
root = config.output_dir / 'sft'
|
||||||
|
manifest_path = root / 'manifest.json'
|
||||||
|
if not manifest_path.exists():
|
||||||
|
raise ValueError('Нет экспорта SFT: выполните generate или export')
|
||||||
|
manifest = json.loads(manifest_path.read_text())
|
||||||
|
total = 0
|
||||||
|
expected_files = set(manifest['files'])
|
||||||
|
actual_files = {str(p.relative_to(root)) for pattern in ('*/*/*.jsonl', '*/*/*.csv') for p in root.glob(pattern)}
|
||||||
|
if expected_files != actual_files:
|
||||||
|
raise ValueError('Набор шардов не соответствует manifest')
|
||||||
|
for relative, checksum in manifest['files'].items():
|
||||||
|
path = root / relative
|
||||||
|
if path.resolve().is_relative_to(root.resolve()) is False or file_digest(path) != checksum:
|
||||||
|
raise ValueError(f'Повреждён шард {relative}')
|
||||||
|
for relative in manifest['files']:
|
||||||
|
path = root / relative
|
||||||
|
if path.suffix == '.jsonl' and '.structured.' not in path.name:
|
||||||
|
counterpart = path.with_suffix('.structured.jsonl')
|
||||||
|
if str(counterpart.relative_to(root)) not in expected_files:
|
||||||
|
raise ValueError('Отсутствует структурированная пара JSONL')
|
||||||
|
with path.open(encoding='utf-8') as file, counterpart.open(encoding='utf-8') as other:
|
||||||
|
for text_line, structured_line in zip_longest(file, other):
|
||||||
|
if text_line is None or structured_line is None:
|
||||||
|
raise ValueError('Разное число записей в паре JSONL')
|
||||||
|
text_row, structured_row = json.loads(text_line), json.loads(structured_line)
|
||||||
|
if (text_row['record_id'] != structured_row['record_id'] or
|
||||||
|
text_row['messages'][1] != structured_row['messages'][1]):
|
||||||
|
raise ValueError('Записи в паре JSONL не совпадают')
|
||||||
|
total += 1
|
||||||
|
if total != manifest['examples']:
|
||||||
|
raise ValueError('Неверное число экспортированных записей')
|
||||||
|
return {'episodes_checked': episodes, 'branch_paths_checked': paths, 'examples_checked': total}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Terminal progress on stderr; dataset statistics remain machine-readable on stdout."""
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.progress import (
|
||||||
|
BarColumn, MofNCompleteColumn, Progress, ProgressColumn,
|
||||||
|
TaskProgressColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn,
|
||||||
|
)
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
|
||||||
|
class RateColumn(ProgressColumn):
|
||||||
|
def render(self, task):
|
||||||
|
speed = task.finished_speed or task.speed
|
||||||
|
return Text(f'{speed:.1f} зап/с' if speed is not None else '— зап/с')
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressDisplay:
|
||||||
|
def __init__(self, label, total, *, initial=0, enabled=True):
|
||||||
|
self.label = label
|
||||||
|
self.total = total
|
||||||
|
self.progress = Progress(
|
||||||
|
TextColumn('{task.description}'), BarColumn(bar_width=None),
|
||||||
|
TaskProgressColumn(), MofNCompleteColumn(), RateColumn(),
|
||||||
|
TextColumn('прошло'), TimeElapsedColumn(),
|
||||||
|
TextColumn('осталось'), TimeRemainingColumn(),
|
||||||
|
console=Console(stderr=True), refresh_per_second=4,
|
||||||
|
redirect_stdout=False, redirect_stderr=False, disable=not enabled,
|
||||||
|
)
|
||||||
|
self.task = self.progress.add_task(label, total=total, completed=min(initial, total))
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.progress.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def update(self, completed=None, *, status=None):
|
||||||
|
kwargs = {}
|
||||||
|
if completed is not None:
|
||||||
|
kwargs['completed'] = min(completed, self.total)
|
||||||
|
if status is not None:
|
||||||
|
kwargs['description'] = f'{self.label}: {status}' if status else self.label
|
||||||
|
self.progress.update(self.task, **kwargs)
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, traceback):
|
||||||
|
if exc_type is not None:
|
||||||
|
self.update(status='прервано' if issubclass(exc_type, (KeyboardInterrupt, SystemExit)) else 'ошибка')
|
||||||
|
self.progress.stop()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Editable prompts and validated payload templates."""
|
||||||
|
from pathlib import Path
|
||||||
|
from string import Template, Formatter
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import model_validator
|
||||||
|
from llm.config import ConfigModel
|
||||||
|
|
||||||
|
DEFAULT_PROMPTS = Path(__file__).resolve().parents[1] / 'prompts.yaml'
|
||||||
|
|
||||||
|
|
||||||
|
class Prompts(ConfigModel):
|
||||||
|
teacher_system: str
|
||||||
|
teacher_user: str
|
||||||
|
teacher_retry: str
|
||||||
|
training_system: dict[str, str]
|
||||||
|
structured_system: dict[str, str]
|
||||||
|
training_user: str
|
||||||
|
task_instructions: dict[str, str]
|
||||||
|
object_names: dict[str, str]
|
||||||
|
action_templates: dict[str, str]
|
||||||
|
|
||||||
|
@model_validator(mode='after')
|
||||||
|
def validate_texts(self):
|
||||||
|
texts = [self.teacher_system, *self.training_system.values(), *self.structured_system.values(), *self.task_instructions.values()]
|
||||||
|
if any(not text.strip() for text in texts):
|
||||||
|
raise ValueError('Промпты не должны быть пустыми')
|
||||||
|
if set(self.training_system) != {'full', 'step', 'branch'}:
|
||||||
|
raise ValueError('training_system должен содержать full, step, branch')
|
||||||
|
if set(self.structured_system) != {'full', 'step', 'branch'}:
|
||||||
|
raise ValueError('structured_system должен содержать full, step, branch')
|
||||||
|
for value in (self.teacher_user, self.teacher_retry, self.training_user):
|
||||||
|
template = Template(value)
|
||||||
|
if not template.is_valid() or set(template.get_identifiers()) != {'payload'}:
|
||||||
|
raise ValueError('Шаблон должен содержать ${payload}; другие переменные не поддерживаются')
|
||||||
|
for value in self.action_templates.values():
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError('Пустой шаблон действия')
|
||||||
|
fields = {field for _, field, _, _ in Formatter().parse(value) if field is not None}
|
||||||
|
if fields - {'object', 'target'}:
|
||||||
|
raise ValueError('В action_templates допустимы только {object} и {target}')
|
||||||
|
value.format(object='объект', target='место')
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def load_prompts(path: Path = DEFAULT_PROMPTS) -> Prompts:
|
||||||
|
return Prompts.model_validate(yaml.safe_load(path.read_text(encoding='utf-8')))
|
||||||
|
|
||||||
|
|
||||||
|
def render(template: str, payload: str) -> str:
|
||||||
|
return Template(template).substitute(payload=payload)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Public skill plans derived from the same validated actions as Russian text."""
|
||||||
|
from .language import component_for, object_name, target_name
|
||||||
|
|
||||||
|
PLACEMENT_SKILLS = {
|
||||||
|
'position', 'place', 'align', 'insert', 'insert_fastener', 'tighten',
|
||||||
|
'press_fit', 'route_wire', 'connect', 'seat_component', 'bond', 'clamp',
|
||||||
|
'weld', 'move_to_storage',
|
||||||
|
}
|
||||||
|
OPERATOR_SKILLS = {'move_to_operator', 'hold', 'handover'}
|
||||||
|
|
||||||
|
|
||||||
|
def public_action(action, scene, prompts):
|
||||||
|
component = component_for(action, scene)
|
||||||
|
skill = action['skill']
|
||||||
|
result = {'skill': skill, 'object': object_name(component, scene, prompts)}
|
||||||
|
if skill in PLACEMENT_SKILLS:
|
||||||
|
result['target'] = target_name(component, scene)
|
||||||
|
elif skill in OPERATOR_SKILLS:
|
||||||
|
result['target'] = 'оператор'
|
||||||
|
elif skill == 'return_home':
|
||||||
|
result.update(object='робот', target='исходное положение')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def structured_answer(episode, mode, index, prompts):
|
||||||
|
actions = episode['plan']['actions']
|
||||||
|
status = episode['plan']['status']
|
||||||
|
if mode == 'step' and actions:
|
||||||
|
actions = actions[index:index + 1]
|
||||||
|
status = 'ready'
|
||||||
|
plan = [public_action(action, episode['scene'], prompts) for action in actions]
|
||||||
|
if mode == 'branch':
|
||||||
|
for i, action in enumerate(plan):
|
||||||
|
action.update(id=f'step_{i + 1}',
|
||||||
|
on_success=f'step_{i + 2}' if i + 1 < len(plan) else status,
|
||||||
|
on_failure='replan_required')
|
||||||
|
return {'status': status, 'plan': plan}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Optional teacher plans through the project's existing model factory."""
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field, field_validator
|
||||||
|
from llm.config import ConfigModel
|
||||||
|
from .prompts import render
|
||||||
|
from .language import validate_texts, action_text
|
||||||
|
from .world import canonical, validate_plan, branch_plan, validate_branch
|
||||||
|
|
||||||
|
|
||||||
|
class Action(ConfigModel):
|
||||||
|
skill: str
|
||||||
|
object: str
|
||||||
|
target: str
|
||||||
|
text: str = Field(description='Одно действие на русском языке с названием объекта из сцены; без кода')
|
||||||
|
|
||||||
|
@field_validator('text')
|
||||||
|
@classmethod
|
||||||
|
def russian_description(cls, value):
|
||||||
|
value = value.strip()
|
||||||
|
if not re.search('[А-Яа-яЁё]', value) or re.search(r'\b(?:part_|slot_)\d+|\b(?:robot|camera)\.', value):
|
||||||
|
raise ValueError('Ожидается русское описание действия, без технических идентификаторов')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class Plan(ConfigModel):
|
||||||
|
status: Literal['ready', 'blocked']
|
||||||
|
actions: list[Action] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_agent(config, prompts):
|
||||||
|
from pydantic_ai import Agent
|
||||||
|
from llm.factory import LLMModelProvider
|
||||||
|
model = await LLMModelProvider.create_from_config(config.llm)
|
||||||
|
return Agent(model=model, output_type=Plan, retries=config.retries,
|
||||||
|
system_prompt=prompts.teacher_system)
|
||||||
|
|
||||||
|
|
||||||
|
async def enrich(episode, config, agent, prompts):
|
||||||
|
if int(episode['signature'][16:32], 16) / 2**64 >= config.fraction:
|
||||||
|
return episode, None
|
||||||
|
teacher_scene = {**episode['scene'], 'operations': [
|
||||||
|
{**operation, 'reference_text': action_text(operation['action'], episode['scene'], prompts)}
|
||||||
|
for operation in episode['scene']['operations']]}
|
||||||
|
prompt = render(prompts.teacher_user, canonical({'instruction': episode['instruction'], 'scene': teacher_scene}))
|
||||||
|
error = None
|
||||||
|
for attempt in range(config.retries + 1):
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(config.timeout_seconds):
|
||||||
|
result = await agent.run(prompt, model_settings=config.generation.model_dump(exclude_none=True))
|
||||||
|
raw = result.output.model_dump()
|
||||||
|
texts = [action['text'] for action in raw['actions']]
|
||||||
|
plan = {'status': raw['status'], 'actions': [{k: v for k, v in a.items() if k != 'text'} for a in raw['actions']]}
|
||||||
|
validate_texts({**episode, 'plan': plan}, texts, prompts)
|
||||||
|
trace = validate_plan(episode, plan)
|
||||||
|
branch = branch_plan(plan)
|
||||||
|
validate_branch(episode, branch)
|
||||||
|
episode.update(plan=plan, trace=trace, branch=branch, step_texts=texts, text_source='teacher',
|
||||||
|
source={'provider': config.llm.provider, 'model': config.llm.model})
|
||||||
|
return episode, None
|
||||||
|
except Exception as exc:
|
||||||
|
# API exceptions may contain request headers. Persist the class only.
|
||||||
|
error = {'scenario_id': episode['scenario_id'], 'error_type': type(exc).__name__, 'attempts': attempt + 1}
|
||||||
|
if isinstance(exc, ValueError):
|
||||||
|
prompt = render(prompts.teacher_retry, canonical({'instruction': episode['instruction'], 'scene': teacher_scene,
|
||||||
|
'validation_error': str(exc)[:500]}))
|
||||||
|
# A failed teacher is never silently labelled as a successful teacher plan.
|
||||||
|
episode['teacher_error'] = error
|
||||||
|
return episode, error
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"""Finite symbolic assembly worlds; model output cannot supply its own effects.
|
||||||
|
|
||||||
|
These recipes describe logical prerequisites only, not physical feasibility.
|
||||||
|
Every grounded operation in a scene is supplied by this trusted generator.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .prompts import render
|
||||||
|
from .structured import structured_answer
|
||||||
|
from .language import answer_text, readable_scene, scene_summary, step_texts
|
||||||
|
|
||||||
|
# Skills before release must retain the component; 'installed' is a logical
|
||||||
|
# stage, not an assertion that real geometry or process parameters are safe.
|
||||||
|
RECIPES = {
|
||||||
|
'manipulation': ('Перемести детали в сборочные позиции', ['pick', 'position', 'place', 'release', 'inspect'], None),
|
||||||
|
'mechanical': ('Собери механический узел', ['pick', 'align', 'insert', 'release', 'inspect'], None),
|
||||||
|
'fastening': ('Установи и затяни крепёж', ['pick', 'align', 'insert_fastener', 'tighten', 'release', 'inspect'], 'driver'),
|
||||||
|
'press_fit': ('Собери прессовые соединения', ['pick', 'align', 'press_fit', 'release', 'inspect'], 'press'),
|
||||||
|
'electrical': ('Выполни электромонтаж', ['pick', 'route_wire', 'connect', 'release', 'test_continuity', 'inspect'], 'tester'),
|
||||||
|
'electronics': ('Установи электронные компоненты', ['pick', 'align', 'seat_component', 'release', 'test_connection', 'inspect'], 'tester'),
|
||||||
|
'adhesive': ('Собери клеевые соединения', ['clean_surface', 'apply_adhesive', 'pick', 'align', 'bond', 'release', 'wait_cure', 'inspect'], 'dispenser'),
|
||||||
|
'welding': ('Соедини детали сваркой', ['pick', 'align', 'clamp', 'release', 'weld', 'inspect'], 'welder'),
|
||||||
|
'collaborative': ('Передай детали оператору для сборки', ['wait_operator', 'pick', 'handover', 'release', 'confirm_assembly', 'inspect'], None),
|
||||||
|
'operator_assistance': ('Удерживай коробку возле оператора', ['detect', 'approach', 'pick', 'move_to_operator', 'hold', 'wait_assembly', 'move_to_storage', 'release', 'return_home'], None),
|
||||||
|
'inspection': ('Проверь детали в контрольной позиции', ['pick', 'position', 'place', 'release', 'measure', 'inspect'], 'gauge'),
|
||||||
|
}
|
||||||
|
# Explicit perception and approach precede grasping in the human-level plan.
|
||||||
|
for _domain, (_title, _recipe, _tool) in list(RECIPES.items()):
|
||||||
|
if _domain != 'operator_assistance':
|
||||||
|
_pick = _recipe.index('pick')
|
||||||
|
RECIPES[_domain] = (_title, _recipe[:_pick] + ['detect', 'approach'] + _recipe[_pick:], _tool)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(value):
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
|
||||||
|
|
||||||
|
|
||||||
|
def digest(value):
|
||||||
|
return hashlib.sha256(canonical(value).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def action_key(action):
|
||||||
|
if not isinstance(action, dict) or set(action) != {'skill', 'object', 'target'}:
|
||||||
|
raise ValueError('Действие должно иметь только skill, object, target')
|
||||||
|
return canonical(action)
|
||||||
|
|
||||||
|
|
||||||
|
def enabled(state, operation):
|
||||||
|
return set(operation['requires']) <= state and not (set(operation['forbids']) & state)
|
||||||
|
|
||||||
|
|
||||||
|
def apply(state, operation):
|
||||||
|
if not enabled(state, operation):
|
||||||
|
raise ValueError(f"Нарушены предусловия: {operation['action']}")
|
||||||
|
return (state - set(operation['delete'])) | set(operation['add'])
|
||||||
|
|
||||||
|
|
||||||
|
def solve(scene):
|
||||||
|
state = set(scene['initial_state'])
|
||||||
|
actions, trace = [], [sorted(state)]
|
||||||
|
remaining = list(scene['operations'])
|
||||||
|
while remaining:
|
||||||
|
operation = next((op for op in remaining if enabled(state, op)), None)
|
||||||
|
if operation is None:
|
||||||
|
break
|
||||||
|
state = apply(state, operation)
|
||||||
|
actions.append(operation['action'])
|
||||||
|
trace.append(sorted(state))
|
||||||
|
remaining.remove(operation)
|
||||||
|
status = 'ready' if set(scene['goal']) <= state else 'blocked'
|
||||||
|
return {'status': status, 'actions': actions}, trace
|
||||||
|
|
||||||
|
|
||||||
|
def validate_plan(episode, plan):
|
||||||
|
if not isinstance(plan, dict) or set(plan) != {'status', 'actions'}:
|
||||||
|
raise ValueError('Ожидаются status и actions')
|
||||||
|
if plan['status'] not in ('ready', 'blocked') or not isinstance(plan['actions'], list):
|
||||||
|
raise ValueError('Неверный статус/список действий')
|
||||||
|
operations = {action_key(op['action']): op for op in episode['scene']['operations']}
|
||||||
|
state = set(episode['scene']['initial_state'])
|
||||||
|
trace = [sorted(state)]
|
||||||
|
for action in plan['actions']:
|
||||||
|
operation = operations.get(action_key(action))
|
||||||
|
if operation is None:
|
||||||
|
raise ValueError(f'Неизвестное действие или объект: {action}')
|
||||||
|
state = apply(state, operation)
|
||||||
|
trace.append(sorted(state))
|
||||||
|
achieved = set(episode['scene']['goal']) <= state
|
||||||
|
if plan['status'] == 'ready' and not achieved:
|
||||||
|
raise ValueError('План не достигает цели')
|
||||||
|
if plan['status'] == 'blocked':
|
||||||
|
# This domain has an acyclic recipe dependency graph. The deterministic
|
||||||
|
# solver is complete for its grounded operations (not arbitrary PDDL).
|
||||||
|
if achieved or solve(episode['scene'])[0]['status'] != 'blocked':
|
||||||
|
raise ValueError('Необоснованная блокировка выполнимой задачи')
|
||||||
|
return trace
|
||||||
|
|
||||||
|
|
||||||
|
def branch_plan(plan):
|
||||||
|
nodes = []
|
||||||
|
for index, action in enumerate(plan['actions']):
|
||||||
|
nodes.append({'id': f'n{index}', 'action': action,
|
||||||
|
'observation': 'action_succeeded',
|
||||||
|
'on_success': f'n{index + 1}' if index + 1 < len(plan['actions']) else 'done',
|
||||||
|
'on_failure': 'replan'})
|
||||||
|
nodes.extend([
|
||||||
|
{'id': 'done', 'terminal': plan['status']},
|
||||||
|
{'id': 'replan', 'terminal': 'replan_required'},
|
||||||
|
])
|
||||||
|
return {'entry': 'n0' if plan['actions'] else 'done', 'nodes': nodes}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_branch(episode, branch):
|
||||||
|
if set(branch) != {'entry', 'nodes'} or not isinstance(branch['nodes'], list):
|
||||||
|
raise ValueError('Неверная схема графа')
|
||||||
|
nodes = {node['id']: node for node in branch['nodes']}
|
||||||
|
if len(nodes) != len(branch['nodes']):
|
||||||
|
raise ValueError('Повторяющиеся узлы')
|
||||||
|
operations = {action_key(op['action']): op for op in episode['scene']['operations']}
|
||||||
|
pending = [(branch['entry'], set(episode['scene']['initial_state']), frozenset(), False)]
|
||||||
|
paths = 0
|
||||||
|
while pending:
|
||||||
|
node_id, state, visited, failed = pending.pop()
|
||||||
|
if node_id not in nodes or node_id in visited:
|
||||||
|
raise ValueError('Отсутствующий узел или цикл')
|
||||||
|
node = nodes[node_id]
|
||||||
|
if failed and node.get('terminal') != 'replan_required':
|
||||||
|
raise ValueError('После сбоя необходима новая сцена и перепланирование')
|
||||||
|
if 'terminal' in node:
|
||||||
|
if set(node) != {'id', 'terminal'}:
|
||||||
|
raise ValueError('Неверный терминальный узел')
|
||||||
|
status = node['terminal']
|
||||||
|
if status == 'ready':
|
||||||
|
if not set(episode['scene']['goal']) <= state:
|
||||||
|
raise ValueError('Ветка не достигает цели')
|
||||||
|
elif status == 'blocked':
|
||||||
|
if solve(episode['scene'])[0]['status'] != 'blocked':
|
||||||
|
raise ValueError('Необоснованная блокировка')
|
||||||
|
elif status == 'replan_required':
|
||||||
|
if not failed:
|
||||||
|
raise ValueError('Перепланирование без наблюдаемого сбоя')
|
||||||
|
else:
|
||||||
|
raise ValueError('Неизвестный терминальный статус')
|
||||||
|
paths += 1
|
||||||
|
continue
|
||||||
|
if set(node) != {'id', 'action', 'observation', 'on_success', 'on_failure'} or node['observation'] != 'action_succeeded':
|
||||||
|
raise ValueError('Неполный узел или неизвестное наблюдение')
|
||||||
|
operation = operations.get(action_key(node['action']))
|
||||||
|
if operation is None:
|
||||||
|
raise ValueError('Неизвестная операция')
|
||||||
|
success = apply(state, operation)
|
||||||
|
visited = visited | {node_id}
|
||||||
|
# The defined failure outcome has no success effects. Stop and obtain a
|
||||||
|
# fresh scene; never blindly retry a physical action after a failure.
|
||||||
|
pending.append((node['on_failure'], state, visited, True))
|
||||||
|
pending.append((node['on_success'], success, visited, failed))
|
||||||
|
if len(pending) + paths > 10000:
|
||||||
|
raise ValueError('Слишком много путей в графе')
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def make_episode(index: int, config: Config):
|
||||||
|
rng = random.Random(f'{config.seed}:{index}')
|
||||||
|
domain = config.domains[index % len(config.domains)]
|
||||||
|
title, recipe, tool = RECIPES[domain]
|
||||||
|
missing = set(recipe) - set(config.prompts.action_templates)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f'В action_templates отсутствуют навыки: {sorted(missing)}')
|
||||||
|
count = rng.randint(config.min_components, config.max_components)
|
||||||
|
state = {'gripper_empty', 'fixture_ready', 'operator_ready'}
|
||||||
|
if domain == 'operator_assistance':
|
||||||
|
state.add('robot_home')
|
||||||
|
if tool:
|
||||||
|
state.add(f'tool_available:{tool}')
|
||||||
|
components, operations = [], []
|
||||||
|
blocked = rng.random() < config.blocked_fraction
|
||||||
|
unavailable = rng.randrange(count) if blocked else -1
|
||||||
|
for i in range(count):
|
||||||
|
obj, target = f'part_{i}', f'slot_{i}'
|
||||||
|
dependencies = [f'part_{j}' for j in range(i) if rng.random() < 0.35]
|
||||||
|
# Positions, part classes and progress have operational meaning, and are
|
||||||
|
# included in the signature. No free-form names enter deduplication.
|
||||||
|
location = rng.choice(['tray', 'buffer', 'conveyor'])
|
||||||
|
components.append({'id': obj, 'target': target, 'type': rng.choice(['small', 'medium', 'large']),
|
||||||
|
'source_location': location, 'dependencies': dependencies,
|
||||||
|
'name': f"{config.prompts.object_names.get(domain, 'деталь')} №{i + 1}",
|
||||||
|
'target_name': f"{'зона хранения' if domain == 'operator_assistance' else 'сборочная позиция'} №{i + 1}"})
|
||||||
|
state.add(f'at:{obj}:{location}')
|
||||||
|
if i != unavailable:
|
||||||
|
state.add(f'available:{obj}')
|
||||||
|
prefix = f'{obj}:stage:'
|
||||||
|
# Some parts are already installed; final inspection is still required.
|
||||||
|
start = len(recipe) - 1 if domain != 'operator_assistance' and i != unavailable and not dependencies and rng.random() < 0.2 else 0
|
||||||
|
state.add(f'{prefix}{start}')
|
||||||
|
for stage, skill in enumerate(recipe):
|
||||||
|
requires = [f'{prefix}{stage}', 'fixture_ready', f'available:{obj}']
|
||||||
|
adds, deletes = [f'{prefix}{stage + 1}'], [f'{prefix}{stage}']
|
||||||
|
if skill == 'pick':
|
||||||
|
requires += ['gripper_empty', f'at:{obj}:{location}']
|
||||||
|
requires += [f'complete:{dependency}' for dependency in dependencies]
|
||||||
|
adds += [f'holding:{obj}']
|
||||||
|
deletes += ['gripper_empty', f'at:{obj}:{location}']
|
||||||
|
elif skill == 'release':
|
||||||
|
requires += [f'holding:{obj}']
|
||||||
|
adds += ['gripper_empty', f'at:{obj}:{target}']
|
||||||
|
deletes += [f'holding:{obj}']
|
||||||
|
elif 'pick' in recipe[:stage] and 'release' not in recipe[:stage]:
|
||||||
|
requires += [f'holding:{obj}']
|
||||||
|
if skill in ('tighten', 'press_fit', 'apply_adhesive', 'weld', 'measure', 'test_connection', 'test_continuity') and tool:
|
||||||
|
requires += [f'tool_available:{tool}']
|
||||||
|
if skill == 'approach':
|
||||||
|
requires += ['gripper_empty']
|
||||||
|
if domain == 'operator_assistance':
|
||||||
|
deletes += ['robot_home']
|
||||||
|
if skill == 'wait_assembly':
|
||||||
|
adds += [f'operator_finished:{obj}']
|
||||||
|
if skill == 'move_to_storage':
|
||||||
|
requires += [f'operator_finished:{obj}']
|
||||||
|
if skill == 'return_home':
|
||||||
|
requires += ['gripper_empty']
|
||||||
|
adds += ['robot_home']
|
||||||
|
if skill in ('wait_operator', 'handover', 'confirm_assembly', 'move_to_operator', 'hold', 'wait_assembly'):
|
||||||
|
requires += ['operator_ready']
|
||||||
|
if stage == len(recipe) - 1:
|
||||||
|
adds += [f'complete:{obj}']
|
||||||
|
operations.append({'action': {'skill': skill, 'object': obj, 'target': target},
|
||||||
|
'requires': requires, 'forbids': [], 'add': adds, 'delete': deletes,
|
||||||
|
'failure_effects': []})
|
||||||
|
if start:
|
||||||
|
state.discard(f'at:{obj}:{location}')
|
||||||
|
state.add(f'at:{obj}:{target}')
|
||||||
|
scene = {'domain': domain, 'components': components,
|
||||||
|
'initial_state': sorted(state), 'goal': ['gripper_empty'] + [f'complete:part_{i}' for i in range(count)],
|
||||||
|
'operations': operations}
|
||||||
|
if domain == 'operator_assistance':
|
||||||
|
scene['goal'].append('robot_home')
|
||||||
|
signature = digest(scene)
|
||||||
|
plan, trace = solve(scene)
|
||||||
|
episode = {'scenario_id': signature, 'signature': signature, 'candidate_index': index,
|
||||||
|
'instruction': config.prompts.task_instructions[domain],
|
||||||
|
'scene': scene, 'plan': plan, 'trace': trace, 'branch': branch_plan(plan),
|
||||||
|
'validation_level': 'logical_only', 'source': {'provider': 'symbolic', 'model': None}}
|
||||||
|
validate_plan(episode, plan)
|
||||||
|
validate_branch(episode, episode['branch'])
|
||||||
|
return episode
|
||||||
|
|
||||||
|
|
||||||
|
def split_for(signature, config):
|
||||||
|
value = int(signature[:16], 16) / 2**64
|
||||||
|
return 'train' if value < config.split[0] else 'validation' if value < sum(config.split[:2]) else 'test'
|
||||||
|
|
||||||
|
|
||||||
|
def training_rows(episode, config, *, output_format='text'):
|
||||||
|
"""All views share the same split; only pre-action state enters step input."""
|
||||||
|
if output_format not in ('text', 'structured'):
|
||||||
|
raise ValueError('Неизвестный формат ответа')
|
||||||
|
common = {'instruction': episode['instruction'],
|
||||||
|
'scene': readable_scene(episode['scene'], episode['scene']['initial_state'], config.prompts)}
|
||||||
|
for mode in config.modes:
|
||||||
|
if mode == 'step':
|
||||||
|
actions = episode['plan']['actions']
|
||||||
|
indices = range(len(actions)) if config.step_sampling == 'all' else [int(episode['signature'][:8], 16) % max(1, len(actions))]
|
||||||
|
if not actions:
|
||||||
|
indices = [0]
|
||||||
|
for step in indices:
|
||||||
|
prompt = deepcopy(common)
|
||||||
|
prompt['scene'] = readable_scene(episode['scene'], episode['trace'][step], config.prompts)
|
||||||
|
prompt['history'] = step_texts(episode, config.prompts)[:step]
|
||||||
|
answer = {'status': 'continue', 'action': actions[step]} if actions else {'status': episode['plan']['status'], 'action': None}
|
||||||
|
yield row(episode, mode, step, prompt, answer, config, output_format)
|
||||||
|
else:
|
||||||
|
prompt = deepcopy(common)
|
||||||
|
if mode == 'branch':
|
||||||
|
prompt['observations'] = ['действие выполнено успешно', 'при выполнении произошла ошибка']
|
||||||
|
yield row(episode, mode, 0, prompt, episode['plan'] if mode == 'full' else episode['branch'], config, output_format)
|
||||||
|
|
||||||
|
|
||||||
|
def row(episode, mode, index, prompt, answer, config, output_format):
|
||||||
|
prompt['mode'] = mode
|
||||||
|
structured = output_format == 'structured'
|
||||||
|
public_answer = structured_answer(episode, mode, index, config.prompts) if structured else None
|
||||||
|
return {'record_id': f"{episode['scenario_id']}:{mode}:{index}",
|
||||||
|
'scenario_id': episode['scenario_id'], 'split': split_for(episode['signature'], config),
|
||||||
|
'mode': mode, 'validation_level': 'logical_only', 'source': episode['source'],
|
||||||
|
'task': episode['instruction'], 'scene_summary': scene_summary(prompt['scene']),
|
||||||
|
'status': public_answer['status'] if structured else (('continue' if episode['plan']['actions'] else episode['plan']['status']) if mode == 'step' else episode['plan']['status']),
|
||||||
|
'text_source': episode.get('text_source', 'template'),
|
||||||
|
'output_format': output_format,
|
||||||
|
'messages': [
|
||||||
|
{'role': 'system', 'content': (config.prompts.structured_system if structured else config.prompts.training_system)[mode]},
|
||||||
|
{'role': 'user', 'content': render(config.prompts.training_user, canonical(prompt))},
|
||||||
|
{'role': 'assistant', 'content': canonical(public_answer) if structured else answer_text(episode, mode, index, config.prompts)},
|
||||||
|
]}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
llm:
|
|
||||||
provider: codex # openai, codex
|
|
||||||
model: gpt-6-astra
|
|
||||||
# model: qwen/qwen3-8b
|
|
||||||
|
|
||||||
generation:
|
|
||||||
temperature: 0.7
|
|
||||||
max_tokens: 4096
|
|
||||||
|
|
||||||
runtime:
|
|
||||||
concurrency: 5 # Колличество одновременных запросов
|
|
||||||
|
|
||||||
prompts:
|
|
||||||
instructions: |
|
|
||||||
Сформируй последовательный и логически корректный план выполнения
|
|
||||||
заданной задачи.
|
|
||||||
|
|
||||||
Соблюдай следующие правила:
|
|
||||||
- один шаг плана должен содержать одно элементарное действие;
|
|
||||||
- действия должны выполняться в физически корректной последовательности;
|
|
||||||
- не объединяй несколько действий в один шаг;
|
|
||||||
- учитывай необходимость безопасного перемещения робота;
|
|
||||||
- перед горизонтальным перемещением с объектом при необходимости
|
|
||||||
сначала подними объект;
|
|
||||||
- перед захватом объекта робот должен переместиться к объекту;
|
|
||||||
- перед освобождением объекта робот должен переместиться в точку
|
|
||||||
его размещения;
|
|
||||||
- не добавляй действия, которые не требуются для выполнения задачи;
|
|
||||||
- не добавляй пояснения, комментарии или рассуждения.
|
|
||||||
|
|
||||||
Используй команды в естественной форме, например:
|
|
||||||
- Переместиться к точке <точка>
|
|
||||||
- Подняться вверх на <расстояние> мм
|
|
||||||
- Опуститься вниз на <расстояние> мм
|
|
||||||
- Захватить <объект>
|
|
||||||
- Освободить <объект>
|
|
||||||
- Повернуть захват на <угол> градусов
|
|
||||||
- Переместиться в исходное положение
|
|
||||||
|
|
||||||
Если необходимые параметры явно указаны в задании, используй их.
|
|
||||||
Не придумывай неизвестные координаты, расстояния, точки или другие
|
|
||||||
параметры, отсутствующие в задании.
|
|
||||||
system: |
|
|
||||||
Ты являешься системой высокоуровневого планирования действий
|
|
||||||
коллаборативного робота.
|
|
||||||
|
|
||||||
Твоя задача — формировать последовательность высокоуровневых
|
|
||||||
команд управления роботом для выполнения заданной технологической
|
|
||||||
операции.
|
|
||||||
user: |
|
|
||||||
Сформируй высокоуровневый план выполнения следующей задачи:
|
|
||||||
|
|
||||||
{task}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
# from pydantic import BaseModel
|
|
||||||
from pydantic_ai import Agent
|
|
||||||
from pydantic_ai.settings import ModelSettings
|
|
||||||
|
|
||||||
from llm.config import load_config
|
|
||||||
from llm.factory import LLMModelProvider, DatasetWriter
|
|
||||||
from llm.schemas import (RobotPlan, RobotDatasetRecord,
|
|
||||||
Prompts, ModelResponse,
|
|
||||||
ModelInfo, GenerationSettings)
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
|
|
||||||
def format_robot_plan(plan: RobotPlan):
|
|
||||||
return "\n".join(
|
|
||||||
f"{step}"
|
|
||||||
for step in plan.steps
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_record(task: str,
|
|
||||||
agent: Agent,
|
|
||||||
settings: ModelSettings,
|
|
||||||
config,
|
|
||||||
semaphore: asyncio.Semaphore) -> RobotDatasetRecord:
|
|
||||||
|
|
||||||
async with semaphore:
|
|
||||||
user_prompt = config.prompts.user.format(task=task)
|
|
||||||
|
|
||||||
result = await agent.run(user_prompt,
|
|
||||||
model_settings=settings)
|
|
||||||
assistant_content = format_robot_plan(result.output)
|
|
||||||
|
|
||||||
return RobotDatasetRecord(
|
|
||||||
task=task,
|
|
||||||
prompts=Prompts(
|
|
||||||
system_prompt=config.prompts.system,
|
|
||||||
instructions=config.prompts.instructions,
|
|
||||||
user_prompt=user_prompt
|
|
||||||
),
|
|
||||||
response=ModelResponse(
|
|
||||||
model_response=result.output.model_dump_json(),
|
|
||||||
assistant_content=assistant_content
|
|
||||||
),
|
|
||||||
model_info=ModelInfo(
|
|
||||||
provider=config.llm.provider,
|
|
||||||
model=config.llm.model
|
|
||||||
),
|
|
||||||
generation=GenerationSettings(
|
|
||||||
temperature=config.generation.temperature,
|
|
||||||
max_tokens=config.generation.max_tokens
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
config = load_config("./build-dataset/config.yml")
|
|
||||||
model = await LLMModelProvider.create_from_config(config.llm)
|
|
||||||
|
|
||||||
|
|
||||||
# model = await LLMModelProvider.create_codex(model_name='gpt-5.6-luna')
|
|
||||||
# model = LLMModelProvider.create_openai_compatible(model_name="qwen/qwen3-8b",
|
|
||||||
# api_key=os.getenv("API_KEY"),
|
|
||||||
# base_url=os.getenv("BASE_URL"))
|
|
||||||
agent = Agent(model=model,
|
|
||||||
output_type=RobotPlan,
|
|
||||||
system_prompt=config.prompts.system,
|
|
||||||
instructions=config.prompts.instructions)
|
|
||||||
settings = ModelSettings(
|
|
||||||
**config.generation.model_dump()
|
|
||||||
)
|
|
||||||
|
|
||||||
tasks = [
|
|
||||||
(
|
|
||||||
"Перемести деталь A из точки PICK_A в точку PLACE_A. "
|
|
||||||
"Безопасная высота подъема — 200 мм."
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Перемести деталь B из точки PICK_B в точку PLACE_B. "
|
|
||||||
"Безопасная высота подъема — 150 мм."
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Возьми крышку из точки COVER_PICK и перемести "
|
|
||||||
"ее в точку COVER_PLACE."
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
semaphore = asyncio.Semaphore(config.runtime.concurrency)
|
|
||||||
|
|
||||||
records = await asyncio.gather(*[
|
|
||||||
generate_record(
|
|
||||||
task,
|
|
||||||
agent,
|
|
||||||
settings,
|
|
||||||
config,
|
|
||||||
semaphore,
|
|
||||||
)
|
|
||||||
for task in tasks
|
|
||||||
])
|
|
||||||
|
|
||||||
for record in records:
|
|
||||||
dataset = DatasetWriter(
|
|
||||||
dataset_record=record,
|
|
||||||
base_path="./dataset"
|
|
||||||
)
|
|
||||||
|
|
||||||
dataset.to_csv(filename="robot-plan.csv")
|
|
||||||
dataset.to_jsonl(filename="robotplan.jsonl")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# Число итоговых обучающих строк СУММАРНО по full/step/branch.
|
||||||
|
# CLI --count имеет приоритет. Можно указать 0, 1, 100, 500000 и т.д.
|
||||||
|
target_examples: 500000
|
||||||
|
seed: 20261021
|
||||||
|
output_dir: dataset/assembly-ru
|
||||||
|
prompts_file: prompts.yaml # инструкции LLM и обучающих записей
|
||||||
|
modes: [full, step, branch]
|
||||||
|
step_sampling: one # one: один шаг на эпизод; all: каждый шаг отдельной строкой
|
||||||
|
shard_size: 1000
|
||||||
|
split: [1.0, 0.0, 0.0] # разделение по сценарию; малые наборы не гарантируют все split
|
||||||
|
min_components: 1
|
||||||
|
max_components: 10
|
||||||
|
blocked_fraction: 0.2
|
||||||
|
domains:
|
||||||
|
- manipulation
|
||||||
|
- mechanical
|
||||||
|
- fastening
|
||||||
|
- press_fit
|
||||||
|
- electrical
|
||||||
|
- electronics
|
||||||
|
- adhesive
|
||||||
|
- welding
|
||||||
|
- collaborative
|
||||||
|
- inspection
|
||||||
|
- operator_assistance
|
||||||
|
teacher:
|
||||||
|
enabled: true # false: полностью локальная генерация без API
|
||||||
|
fraction: 1.0
|
||||||
|
concurrency: 4
|
||||||
|
timeout_seconds: 180
|
||||||
|
retries: 2
|
||||||
|
# llm:
|
||||||
|
# provider: openai
|
||||||
|
# model: your-provider/your-model
|
||||||
|
# api_key_env: API_KEY
|
||||||
|
# base_url_env: BASE_URL
|
||||||
|
# Для существующей интеграции Codex замените llm выше:
|
||||||
|
llm:
|
||||||
|
provider: codex
|
||||||
|
model: gpt-6-astra
|
||||||
|
credential_path: .config/codex-credentials.json
|
||||||
|
generation:
|
||||||
|
temperature: null
|
||||||
|
max_tokens: 8192
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
# Навыки робота
|
||||||
|
|
||||||
|
Справочник 32 навыков, используемых генератором датасета.
|
||||||
|
|
||||||
|
## Формат действия
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"skill": "align", "object": "деталь №1", "target": "сборочная позиция №1"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `skill` — фиксированное имя навыка на английском; по нему программа выбирает обработчик.
|
||||||
|
- `object` — русское название объекта из сцены. Для `return_home` — `робот`.
|
||||||
|
- `target` — русское название цели действия. Поле присутствует только у навыков, отмеченных в таблице.
|
||||||
|
|
||||||
|
## Список навыков
|
||||||
|
|
||||||
|
| Навык | Назначение | Значение `target` |
|
||||||
|
|---|---|---|
|
||||||
|
| `detect` | Определить положение объекта | — |
|
||||||
|
| `approach` | Подвести робот к объекту | — |
|
||||||
|
| `pick` | Захватить объект | — |
|
||||||
|
| `position` | Переместить объект к месту назначения | Место назначения |
|
||||||
|
| `place` | Установить объект на место, сохраняя захват | Место установки |
|
||||||
|
| `align` | Совместить объект с местом установки | Место установки |
|
||||||
|
| `insert` | Вставить объект | Место установки |
|
||||||
|
| `release` | Отпустить объект | — |
|
||||||
|
| `inspect` | Проверить результат установки объекта | — |
|
||||||
|
| `insert_fastener` | Установить крепёж в отверстие | Место установки |
|
||||||
|
| `tighten` | Затянуть крепёж | Место установки |
|
||||||
|
| `press_fit` | Запрессовать объект | Место установки |
|
||||||
|
| `route_wire` | Проложить провод | Место подключения |
|
||||||
|
| `connect` | Подключить провод | Место подключения |
|
||||||
|
| `test_continuity` | Проверить электрическую целостность соединения провода | — |
|
||||||
|
| `seat_component` | Установить электронный компонент | Место установки |
|
||||||
|
| `test_connection` | Проверить подключение электронного компонента | — |
|
||||||
|
| `clean_surface` | Очистить поверхность объекта для склеивания | — |
|
||||||
|
| `apply_adhesive` | Нанести клей на объект | — |
|
||||||
|
| `bond` | Прижать объект к месту соединения | Место соединения |
|
||||||
|
| `wait_cure` | Дождаться отверждения клея на соединении | — |
|
||||||
|
| `clamp` | Зафиксировать объект в приспособлении | Место фиксации |
|
||||||
|
| `weld` | Выполнить сварное соединение | Место соединения |
|
||||||
|
| `measure` | Проверить размеры объекта | — |
|
||||||
|
| `wait_operator` | Дождаться готовности оператора принять объект | — |
|
||||||
|
| `handover` | Поднести объект для передачи оператору, сохраняя захват | `оператор` |
|
||||||
|
| `confirm_assembly` | Получить подтверждение сборки объекта от оператора | — |
|
||||||
|
| `move_to_operator` | Переместить объект к оператору | `оператор` |
|
||||||
|
| `hold` | Удерживать объект в неподвижном состоянии возле оператора | `оператор` |
|
||||||
|
| `wait_assembly` | Дождаться подтверждения завершения сборки, продолжая удерживать объект | — |
|
||||||
|
| `move_to_storage` | Переместить объект в зону хранения | Зона хранения |
|
||||||
|
| `return_home` | Вернуться в исходное положение | `исходное положение` |
|
||||||
|
|
||||||
|
## Форматы ответа модели
|
||||||
|
|
||||||
|
### Полный план — `full`
|
||||||
|
|
||||||
|
Ответ содержит `status` и массив `plan`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ready",
|
||||||
|
"plan": [
|
||||||
|
{"skill": "detect", "object": "распределительная коробка №1"},
|
||||||
|
{"skill": "approach", "object": "распределительная коробка №1"},
|
||||||
|
{"skill": "pick", "object": "распределительная коробка №1"},
|
||||||
|
{"skill": "move_to_operator", "object": "распределительная коробка №1", "target": "оператор"},
|
||||||
|
{"skill": "hold", "object": "распределительная коробка №1", "target": "оператор"},
|
||||||
|
{"skill": "wait_assembly", "object": "распределительная коробка №1"},
|
||||||
|
{"skill": "move_to_storage", "object": "распределительная коробка №1", "target": "зона хранения №1"},
|
||||||
|
{"skill": "release", "object": "распределительная коробка №1"},
|
||||||
|
{"skill": "return_home", "object": "робот", "target": "исходное положение"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ready` — полный план достигает цели в логической модели сцены.
|
||||||
|
- `blocked` — цель недостижима в текущей сцене. План может содержать выполнимую часть действий; если действий нет, массив пуст.
|
||||||
|
|
||||||
|
### Следующее действие — `step`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ready",
|
||||||
|
"plan": [{"skill": "pick", "object": "деталь №1"}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
В этом режиме `ready` означает доступность указанного следующего действия, а не гарантию завершения всей задачи. При достигнутой цели возвращается `ready` с пустым массивом; при невозможности продолжения — `blocked` с пустым массивом.
|
||||||
|
|
||||||
|
### План с ветвлением — `branch`
|
||||||
|
|
||||||
|
У каждого действия дополнительно присутствуют:
|
||||||
|
|
||||||
|
| Поле | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `id` | Идентификатор действия: `step_1`, `step_2` и далее |
|
||||||
|
| `on_success` | ID следующего действия или терминальный исход `ready` / `blocked` |
|
||||||
|
| `on_failure` | Терминальный исход `replan_required` |
|
||||||
|
|
||||||
|
Пример отдельного узла внутри плана:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "step_1",
|
||||||
|
"skill": "pick",
|
||||||
|
"object": "деталь №1",
|
||||||
|
"on_success": "step_2",
|
||||||
|
"on_failure": "replan_required"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Выполнение начинается с первого элемента `plan`. При успехе последнего действия переход ведёт к `ready` или `blocked`. При ошибке `replan_required` означает остановку, обновление сцены и построение нового плана. Эти три исхода не являются навыками. У пустого плана итог определяется полем `status`.
|
||||||
|
|
||||||
+1
-30
@@ -1,7 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
import yaml
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -31,36 +30,8 @@ LLMConfig = Annotated[
|
|||||||
|
|
||||||
# Generation
|
# Generation
|
||||||
class GenerationConfig(ConfigModel):
|
class GenerationConfig(ConfigModel):
|
||||||
temperature: float = Field(default=.7,
|
temperature: float | None = Field(default=None,
|
||||||
ge=.0,
|
ge=.0,
|
||||||
le=2.0)
|
le=2.0)
|
||||||
max_tokens: int = Field(default=4096,
|
max_tokens: int = Field(default=4096,
|
||||||
gt=0)
|
gt=0)
|
||||||
|
|
||||||
|
|
||||||
# Runtime
|
|
||||||
class RuntimeConfig(ConfigModel):
|
|
||||||
concurrency: int = Field(ge=1, default=1)
|
|
||||||
|
|
||||||
# Prompts
|
|
||||||
class PromptsConfig(ConfigModel):
|
|
||||||
instructions: str
|
|
||||||
system: str
|
|
||||||
user: str
|
|
||||||
|
|
||||||
# Application
|
|
||||||
class AppConfig(ConfigModel):
|
|
||||||
llm: LLMConfig
|
|
||||||
generation: GenerationConfig = GenerationConfig()
|
|
||||||
runtime: RuntimeConfig = RuntimeConfig()
|
|
||||||
prompts: PromptsConfig
|
|
||||||
|
|
||||||
|
|
||||||
# Loader
|
|
||||||
def load_config(path: str | Path = "config.yaml") -> AppConfig:
|
|
||||||
path = Path(path)
|
|
||||||
|
|
||||||
with path.open("r", encoding="utf-8") as file:
|
|
||||||
data = yaml.safe_load(file)
|
|
||||||
|
|
||||||
return AppConfig.model_validate(data)
|
|
||||||
+1
-68
@@ -1,6 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
import webbrowser
|
import webbrowser
|
||||||
import pandas as pd
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_ai.models.openai import OpenAIChatModel
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
@@ -13,7 +12,6 @@ from pydantic_ai.providers.openai_codex import (
|
|||||||
|
|
||||||
from .config import CodexConfig, LLMConfig, OpenAiConfig
|
from .config import CodexConfig, LLMConfig, OpenAiConfig
|
||||||
from .credentials import FileCodexCredentionalSource
|
from .credentials import FileCodexCredentionalSource
|
||||||
from .schemas import BaseDatasetRecord, ChatRecord, ChatMessage
|
|
||||||
|
|
||||||
|
|
||||||
class LLMModelProvider:
|
class LLMModelProvider:
|
||||||
@@ -22,7 +20,7 @@ class LLMModelProvider:
|
|||||||
def create_openai_compatible(model_name: str,
|
def create_openai_compatible(model_name: str,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
base_url: str
|
base_url: str
|
||||||
) -> OpenAICodexModel:
|
) -> OpenAIChatModel:
|
||||||
|
|
||||||
provider = OpenAIProvider(base_url=base_url,
|
provider = OpenAIProvider(base_url=base_url,
|
||||||
api_key=api_key)
|
api_key=api_key)
|
||||||
@@ -84,68 +82,3 @@ class LLMModelProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
raise ValueError(f"Unsupported LLM config: {type(config)}")
|
raise ValueError(f"Unsupported LLM config: {type(config)}")
|
||||||
|
|
||||||
class DatasetWriter:
|
|
||||||
def __init__(self,
|
|
||||||
dataset_record: BaseDatasetRecord,
|
|
||||||
base_path: str | Path):
|
|
||||||
|
|
||||||
|
|
||||||
self._dataset_record = dataset_record
|
|
||||||
self._path = Path(base_path)
|
|
||||||
self._path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
def __check_file_exists(self, filename :str)-> bool:
|
|
||||||
file_path = self._path / filename
|
|
||||||
return file_path.exists()
|
|
||||||
|
|
||||||
def to_csv(self,
|
|
||||||
filename: str) -> None:
|
|
||||||
|
|
||||||
exists=self.__check_file_exists(filename)
|
|
||||||
|
|
||||||
df = pd.json_normalize(
|
|
||||||
self._dataset_record.model_dump()
|
|
||||||
)
|
|
||||||
|
|
||||||
df.to_csv(self._path / filename,
|
|
||||||
mode="a" if exists else "w",
|
|
||||||
header=not exists,
|
|
||||||
index=False,
|
|
||||||
encoding="utf-8")
|
|
||||||
|
|
||||||
def to_chat_record(self) -> ChatRecord:
|
|
||||||
response = self._dataset_record.response
|
|
||||||
prompts = self._dataset_record.prompts
|
|
||||||
|
|
||||||
assistant_content = (
|
|
||||||
response.assistant_content
|
|
||||||
or response.model_response
|
|
||||||
)
|
|
||||||
|
|
||||||
return ChatRecord(
|
|
||||||
messages=[
|
|
||||||
ChatMessage(
|
|
||||||
role="system",
|
|
||||||
content=prompts.system_prompt,
|
|
||||||
),
|
|
||||||
ChatMessage(
|
|
||||||
role="user",
|
|
||||||
content=prompts.user_prompt,
|
|
||||||
),
|
|
||||||
ChatMessage(
|
|
||||||
role="assistant",
|
|
||||||
content=assistant_content,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_jsonl(self,
|
|
||||||
filename: str) -> None:
|
|
||||||
|
|
||||||
chat_record = self.to_chat_record()
|
|
||||||
path = self._path/filename
|
|
||||||
|
|
||||||
with path.open("a", encoding='utf-8') as file:
|
|
||||||
file.write(chat_record.model_dump_json())
|
|
||||||
file.write("\n")
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
from typing import Literal, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class RobotPlan(BaseModel):
|
|
||||||
steps: list[str] = Field(
|
|
||||||
description="Последовательность высокоуровневых действий робота",
|
|
||||||
min_length=1
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ChatMessage(BaseModel):
|
|
||||||
role: Literal["system",
|
|
||||||
"user",
|
|
||||||
"assistant"]
|
|
||||||
|
|
||||||
content: str
|
|
||||||
|
|
||||||
|
|
||||||
class ChatRecord(BaseModel):
|
|
||||||
messages: list[ChatMessage]
|
|
||||||
|
|
||||||
class Prompts(BaseModel):
|
|
||||||
system_prompt: str
|
|
||||||
instructions: str
|
|
||||||
user_prompt: str
|
|
||||||
|
|
||||||
|
|
||||||
class ModelResponse(BaseModel):
|
|
||||||
model_response: str
|
|
||||||
assistant_content: Optional[str] = None
|
|
||||||
|
|
||||||
class ModelInfo(BaseModel):
|
|
||||||
provider: str
|
|
||||||
model: str
|
|
||||||
|
|
||||||
class GenerationSettings(BaseModel):
|
|
||||||
temperature: float
|
|
||||||
max_tokens: int
|
|
||||||
|
|
||||||
class BaseDatasetRecord(BaseModel):
|
|
||||||
prompts: Prompts
|
|
||||||
response: ModelResponse
|
|
||||||
model_info: ModelInfo
|
|
||||||
generation: GenerationSettings
|
|
||||||
|
|
||||||
class RobotDatasetRecord(BaseDatasetRecord):
|
|
||||||
task: str
|
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
# Инструкции и тексты планов. В шаблонах user/retry сохраните ${payload}.
|
||||||
|
# action_templates: {object} — название объекта, {target} — место назначения.
|
||||||
|
teacher_system: |-
|
||||||
|
Составь последовательность действий робота на русском языке по команде и сцене.
|
||||||
|
Это высокоуровневый план для человека, а не программа управления: без Python,
|
||||||
|
ROS, координат приводов и записей вида pick: part_0 -> slot_0.
|
||||||
|
Для каждого действия верни text — самостоятельную русскую фразу с названием
|
||||||
|
объекта из scene.components[].name. Например: «Захватить объект „распределительная
|
||||||
|
коробка №1“», «Переместить объект „распределительная коробка №1“ к оператору», «Дождаться завершения сборки».
|
||||||
|
Одновременно укажи skill, object и target из доступных операций: эти поля нужны
|
||||||
|
только внутреннему валидатору и не попадут в обучающий текст ответа.
|
||||||
|
Текст должен точно описывать выбранную операцию, без дополнительных действий.
|
||||||
|
Соблюдай requires/forbids и эффекты add/delete. ready означает достижение goal;
|
||||||
|
blocked допустим только если цель недостижима. Не меняй постановку задачи.
|
||||||
|
Не отпускай удерживаемый объект до завершения работы, если задача требует удержания.
|
||||||
|
Каждая операция содержит reference_text — проверенное русское описание.
|
||||||
|
Используй его в text. Допускаются только изменения пунктуации, вводные
|
||||||
|
«Сначала», «Затем», «Далее» и замены: «переместить»/«перенести»,
|
||||||
|
«захватить»/«взять», «проверить»/«проконтролировать»,
|
||||||
|
«вернуться»/«возвратиться», «положение»/«местоположение».
|
||||||
|
Не добавляй действия, отрицания или условия, отсутствующие в reference_text.
|
||||||
|
teacher_user: ${payload}
|
||||||
|
teacher_retry: |-
|
||||||
|
Исправь план с учётом ошибки проверки.
|
||||||
|
${payload}
|
||||||
|
training_system:
|
||||||
|
full: |-
|
||||||
|
Составь полную последовательность действий робота на русском языке по заданию и сцене. Ответ — нумерованный список понятных действий с названиями объектов. Учитывай исходное состояние, зависимости и условия ожидания. Не пиши JSON, программный код или технические имена навыков. Если задача невыполнима, явно сообщи об этом.
|
||||||
|
step: |-
|
||||||
|
Укажи одно следующее действие робота на русском языке с учётом задания, текущей сцены и истории. Используй названия объектов. Не повторяй уже выполненные действия. Не пиши JSON или программный код. Если продолжить невозможно, сообщи об этом.
|
||||||
|
branch: |-
|
||||||
|
Составь план действий робота на русском языке с условиями продолжения: что делать при успехе и при ошибке. Используй нумерованные пункты и понятные названия объектов. При сбое остановись и запроси перепланирование по обновлённой сцене. Не пиши JSON, программный код или технические идентификаторы узлов.
|
||||||
|
training_user: ${payload}
|
||||||
|
task_instructions:
|
||||||
|
manipulation: |-
|
||||||
|
Перемести детали в сборочные позиции. Соблюдай зависимости деталей и проверь результат
|
||||||
|
каждой операции.
|
||||||
|
mechanical: Собери механический узел. Соблюдай зависимости деталей и проверь результат каждой операции.
|
||||||
|
fastening: Установи и затяни крепёж. Соблюдай зависимости деталей и проверь результат каждой операции.
|
||||||
|
press_fit: Собери прессовые соединения. Соблюдай зависимости деталей и проверь результат каждой операции.
|
||||||
|
electrical: Выполни электромонтаж. Соблюдай зависимости деталей и проверь результат каждой операции.
|
||||||
|
electronics: Установи электронные компоненты. Соблюдай зависимости деталей и проверь результат каждой операции.
|
||||||
|
adhesive: Собери клеевые соединения. Соблюдай зависимости деталей и проверь результат каждой операции.
|
||||||
|
welding: Соедини детали сваркой. Соблюдай зависимости деталей и проверь результат каждой операции.
|
||||||
|
collaborative: |-
|
||||||
|
Передай детали оператору для сборки. Соблюдай зависимости деталей и проверь результат каждой
|
||||||
|
операции.
|
||||||
|
inspection: |-
|
||||||
|
Проверь детали в контрольной позиции. Соблюдай зависимости деталей и проверь результат
|
||||||
|
каждой операции.
|
||||||
|
operator_assistance: |-
|
||||||
|
Удерживай распределительную коробку возле оператора, пока он устанавливает элементы. Когда сборка закончится, убери коробку на место хранения и вернись в исходное положение.
|
||||||
|
object_names:
|
||||||
|
manipulation: деталь
|
||||||
|
mechanical: деталь
|
||||||
|
fastening: крепёж
|
||||||
|
press_fit: деталь
|
||||||
|
electrical: провод
|
||||||
|
electronics: электронный компонент
|
||||||
|
adhesive: деталь
|
||||||
|
welding: деталь
|
||||||
|
collaborative: деталь
|
||||||
|
inspection: деталь
|
||||||
|
operator_assistance: распределительная коробка
|
||||||
|
action_templates:
|
||||||
|
detect: Определить положение объекта «{object}».
|
||||||
|
approach: Переместиться к объекту «{object}».
|
||||||
|
pick: Захватить объект «{object}».
|
||||||
|
position: Переместить объект «{object}» к месту «{target}».
|
||||||
|
place: Установить объект «{object}» в место «{target}».
|
||||||
|
release: Отпустить объект «{object}».
|
||||||
|
inspect: Проверить результат установки объекта «{object}».
|
||||||
|
align: Совместить объект «{object}» с местом установки «{target}».
|
||||||
|
insert: Вставить объект «{object}» в место «{target}».
|
||||||
|
insert_fastener: Установить объект «{object}» в крепёжное отверстие «{target}».
|
||||||
|
tighten: Затянуть крепёж «{object}» в месте «{target}».
|
||||||
|
press_fit: Запрессовать объект «{object}» в место «{target}».
|
||||||
|
route_wire: Проложить провод «{object}» к месту подключения «{target}».
|
||||||
|
connect: Подключить провод «{object}» в месте «{target}».
|
||||||
|
test_continuity: Проверить электрическую целостность соединения провода «{object}».
|
||||||
|
seat_component: Установить электронный компонент «{object}» в место «{target}».
|
||||||
|
test_connection: Проверить подключение компонента «{object}».
|
||||||
|
clean_surface: Очистить поверхность объекта «{object}» для склеивания.
|
||||||
|
apply_adhesive: Нанести клеевой состав на объект «{object}».
|
||||||
|
bond: Прижать объект «{object}» к месту соединения «{target}».
|
||||||
|
wait_cure: Дождаться отверждения клея на соединении объекта «{object}».
|
||||||
|
clamp: Зафиксировать объект «{object}» в приспособлении «{target}».
|
||||||
|
weld: Выполнить сварное соединение объекта «{object}» в месте «{target}».
|
||||||
|
wait_operator: Дождаться готовности оператора принять объект «{object}».
|
||||||
|
handover: Передать объект «{object}» оператору, сохраняя захват до отпускания.
|
||||||
|
confirm_assembly: Получить подтверждение сборки объекта «{object}» от оператора.
|
||||||
|
measure: Проверить размеры объекта «{object}».
|
||||||
|
move_to_operator: Переместить объект «{object}» к оператору.
|
||||||
|
hold: Удерживать объект «{object}» в неподвижном состоянии возле оператора.
|
||||||
|
wait_assembly: Дождаться подтверждения завершения сборки объекта «{object}», продолжая удерживать его.
|
||||||
|
move_to_storage: Переместить объект «{object}» в место «{target}».
|
||||||
|
return_home: Вернуться в исходное положение.
|
||||||
|
|
||||||
|
# Инструкции для второго JSONL. Названия навыков остаются английскими.
|
||||||
|
structured_system:
|
||||||
|
full: |-
|
||||||
|
Составь полный план по заданию и сцене. Верни только JSON с полями status и plan.
|
||||||
|
status: ready, если цель достижима; blocked, если задача невыполнима.
|
||||||
|
plan — массив действий с skill, object и, когда требуется место назначения, target.
|
||||||
|
skill — имя навыка на английском; object и target — русские названия из сцены.
|
||||||
|
Для pick, release и inspect не добавляй target. Не используй технические ID объектов.
|
||||||
|
При blocked сохрани выполнимую часть плана; если действий нет, верни пустой массив.
|
||||||
|
step: |-
|
||||||
|
Укажи следующее действие с учётом текущей сцены и истории. Верни только JSON
|
||||||
|
с полями status и plan. Если действие доступно, status — ready, plan содержит
|
||||||
|
ровно одно действие с skill, object и необязательным target. Если цель уже
|
||||||
|
достигнута, верни ready и пустой plan; если продолжить невозможно — blocked
|
||||||
|
и пустой plan. skill — имя навыка на английском, object и target — русские
|
||||||
|
названия. Для pick, release и inspect target не нужен. Не повторяй историю.
|
||||||
|
branch: |-
|
||||||
|
Составь план с ветвлением. Верни только JSON с полями status и plan.
|
||||||
|
status — ready или blocked для полного сценария. plan — массив действий:
|
||||||
|
skill, object, необязательный target, id, on_success, on_failure.
|
||||||
|
skill — имя навыка на английском; object и target — русские названия.
|
||||||
|
id имеет вид step_1, step_2 и далее. Выполнение начинается с первого элемента.
|
||||||
|
on_success указывает следующий id, а в последнем действии — ready или blocked.
|
||||||
|
on_failure всегда replan_required: остановиться, обновить сцену и перепланировать.
|
||||||
|
ready, blocked и replan_required — терминальные исходы, а не ID действий.
|
||||||
|
Пустой plan означает отсутствие действий; итог задан status.
|
||||||
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "llm-robot-control"
|
name = "llm-robot-control"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Add your description here"
|
description = "Synthetic datasets for high-level industrial assembly planning"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"numpy>=2.5.3",
|
|
||||||
"pandas>=3.0.6",
|
|
||||||
"pydantic>=2.13.5",
|
"pydantic>=2.13.5",
|
||||||
"pydantic-ai>=2.51.0",
|
"pydantic-ai>=2.51.0",
|
||||||
"python-dotenv>=1.2.3",
|
"python-dotenv>=1.2.3",
|
||||||
|
"pyyaml>=6.0.3",
|
||||||
|
"rich>=15.0.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Behavioral tests for exact sizes, restart safety and logical plan validation."""
|
||||||
|
import asyncio
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class AssemblyTests(unittest.TestCase):
|
||||||
|
def test_preinstalled_parts_do_not_depend_on_unfinished_assembly(self):
|
||||||
|
from assembly_dataset.world import make_episode, RECIPES
|
||||||
|
from assembly_dataset.config import Config
|
||||||
|
for index in range(100):
|
||||||
|
episode = make_episode(index, Config())
|
||||||
|
scene = episode['scene']
|
||||||
|
last = len(RECIPES[scene['domain']][1]) - 1
|
||||||
|
for component in scene['components']:
|
||||||
|
if f"{component['id']}:stage:{last}" in scene['initial_state']:
|
||||||
|
self.assertEqual(component['dependencies'], [])
|
||||||
|
|
||||||
|
def test_exact_counts_resume_and_split_grouping(self):
|
||||||
|
from assembly_dataset.config import Config
|
||||||
|
from assembly_dataset.pipeline import generate, export
|
||||||
|
for count in (0, 1, 2, 7, 100):
|
||||||
|
with self.subTest(count=count), tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = Config(output_dir=Path(tmp), target_examples=count, shard_size=11)
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
export(config)
|
||||||
|
rows = [json.loads(line) for p in Path(tmp).glob('sft/*/*/[0-9][0-9][0-9][0-9][0-9][0-9].jsonl')
|
||||||
|
for line in p.read_text().splitlines()]
|
||||||
|
self.assertEqual(len(rows), count)
|
||||||
|
self.assertEqual(len({r['record_id'] for r in rows}), count)
|
||||||
|
groups = {}
|
||||||
|
for r in rows:
|
||||||
|
self.assertEqual(groups.setdefault(r['scenario_id'], r['split']), r['split'])
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
export(config)
|
||||||
|
self.assertEqual(sum(len(p.read_text().splitlines()) for p in Path(tmp).glob('sft/*/*/[0-9][0-9][0-9][0-9][0-9][0-9].jsonl')), count)
|
||||||
|
config.target_examples = count + 4
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
export(config)
|
||||||
|
self.assertEqual(sum(len(p.read_text().splitlines()) for p in Path(tmp).glob('sft/*/*/[0-9][0-9][0-9][0-9][0-9][0-9].jsonl')), count + 4)
|
||||||
|
|
||||||
|
def test_rejects_changed_generation_settings_on_resume(self):
|
||||||
|
from assembly_dataset.config import Config
|
||||||
|
from assembly_dataset.pipeline import generate
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = Config(output_dir=Path(tmp), target_examples=4)
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
config.seed += 1
|
||||||
|
with self.assertRaisesRegex(ValueError, 'config|конфиг'):
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
|
||||||
|
def test_plans_and_branches_are_executable(self):
|
||||||
|
from assembly_dataset.world import make_episode, validate_plan, validate_branch
|
||||||
|
from assembly_dataset.config import Config
|
||||||
|
for index in range(100):
|
||||||
|
episode = make_episode(index, Config())
|
||||||
|
validate_plan(episode, episode['plan'])
|
||||||
|
validate_branch(episode, episode['branch'])
|
||||||
|
if episode['plan']['actions']:
|
||||||
|
invalid = copy.deepcopy(episode['plan'])
|
||||||
|
invalid['actions'][0]['object'] = 'does_not_exist'
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
validate_plan(episode, invalid)
|
||||||
|
invalid_branch = copy.deepcopy(episode['branch'])
|
||||||
|
invalid_branch['nodes'][0]['on_failure'] = 'missing_node'
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
validate_branch(episode, invalid_branch)
|
||||||
|
|
||||||
|
def test_missing_goal_and_cycles_are_rejected(self):
|
||||||
|
from assembly_dataset.world import make_episode, validate_plan, validate_branch
|
||||||
|
from assembly_dataset.config import Config
|
||||||
|
episode = next(e for i in range(100) if (e := make_episode(i, Config()))['plan']['status'] == 'ready')
|
||||||
|
broken = copy.deepcopy(episode['plan'])
|
||||||
|
broken['actions'] = []
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
validate_plan(episode, broken)
|
||||||
|
branch = copy.deepcopy(episode['branch'])
|
||||||
|
branch['nodes'][0]['on_success'] = branch['nodes'][0]['id']
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
validate_branch(episode, branch)
|
||||||
|
|
||||||
|
def test_failure_branch_requires_fresh_scene_before_another_action(self):
|
||||||
|
from assembly_dataset.world import make_episode, validate_branch
|
||||||
|
from assembly_dataset.config import Config
|
||||||
|
episode = make_episode(0, Config(blocked_fraction=0))
|
||||||
|
branch = copy.deepcopy(episode['branch'])
|
||||||
|
retry = copy.deepcopy(branch['nodes'][0])
|
||||||
|
retry['id'] = 'retry'
|
||||||
|
branch['nodes'][0]['on_failure'] = 'retry'
|
||||||
|
branch['nodes'].append(retry)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
validate_branch(episode, branch)
|
||||||
|
|
||||||
|
def test_reproducibility_and_structural_diversity(self):
|
||||||
|
from assembly_dataset.world import make_episode
|
||||||
|
from assembly_dataset.config import Config
|
||||||
|
config = Config()
|
||||||
|
signatures = {make_episode(i, config)['signature'] for i in range(1000)}
|
||||||
|
self.assertGreater(len(signatures), 990)
|
||||||
|
self.assertEqual(make_episode(12, config), make_episode(12, config))
|
||||||
|
|
||||||
|
def test_config_rejects_invalid_counts_modes_and_splits(self):
|
||||||
|
from assembly_dataset.config import Config
|
||||||
|
for kwargs in ({'target_examples': -1}, {'target_examples': 1.5}, {'modes': []},
|
||||||
|
{'modes': ['full', 'full']}, {'split': [0.8, 0.5, 0.1]}):
|
||||||
|
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
|
||||||
|
Config(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from assembly_dataset.config import Config, TeacherConfig
|
||||||
|
from assembly_dataset.pipeline import generate, export, validate
|
||||||
|
from assembly_dataset.teacher import Plan
|
||||||
|
from assembly_dataset.world import solve
|
||||||
|
from assembly_dataset.language import action_text
|
||||||
|
from llm.config import OpenAiConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgent:
|
||||||
|
def __init__(self, invalid=False):
|
||||||
|
self.active = 0
|
||||||
|
self.peak = 0
|
||||||
|
self.invalid = invalid
|
||||||
|
|
||||||
|
async def run(self, prompt, **kwargs):
|
||||||
|
self.active += 1
|
||||||
|
self.peak = max(self.peak, self.active)
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0.001)
|
||||||
|
plan = {'status': 'ready', 'actions': []} if self.invalid else solve(json.loads(prompt)['scene'])[0]
|
||||||
|
if plan['actions']:
|
||||||
|
scene = json.loads(prompt)['scene']
|
||||||
|
plan['actions'] = [{**a, 'text': action_text(a, scene, Config().prompts)} for a in plan['actions']]
|
||||||
|
return SimpleNamespace(output=Plan.model_validate(plan))
|
||||||
|
finally:
|
||||||
|
self.active -= 1
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineTests(unittest.TestCase):
|
||||||
|
def teacher_config(self, path):
|
||||||
|
return Config(output_dir=Path(path), target_examples=30,
|
||||||
|
teacher=TeacherConfig(enabled=True, fraction=1, concurrency=2, retries=1,
|
||||||
|
llm=OpenAiConfig(provider='openai', model='test')))
|
||||||
|
|
||||||
|
def test_teacher_concurrency_and_validated_provenance(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = self.teacher_config(tmp)
|
||||||
|
agent = TestAgent()
|
||||||
|
asyncio.run(generate(config, agent))
|
||||||
|
report = export(config)
|
||||||
|
self.assertEqual(agent.peak, 2)
|
||||||
|
self.assertEqual(report['sources'], {'openai': 10})
|
||||||
|
self.assertEqual(report['teacher_failures'], 0)
|
||||||
|
self.assertEqual(validate(config)['examples_checked'], 30)
|
||||||
|
|
||||||
|
def test_invalid_teacher_cannot_poison_corpus(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = self.teacher_config(tmp)
|
||||||
|
asyncio.run(generate(config, TestAgent(invalid=True)))
|
||||||
|
report = export(config)
|
||||||
|
self.assertEqual(report['teacher_failures'], 10)
|
||||||
|
self.assertEqual(report['sources'], {'symbolic': 10})
|
||||||
|
self.assertEqual(validate(config)['examples_checked'], 30)
|
||||||
|
|
||||||
|
def test_restart_after_interrupted_generation_equals_clean_run(self):
|
||||||
|
import assembly_dataset.pipeline as pipeline
|
||||||
|
original = pipeline.make_episode
|
||||||
|
def interrupted(index, config):
|
||||||
|
if index >= 40:
|
||||||
|
raise KeyboardInterrupt()
|
||||||
|
return original(index, config)
|
||||||
|
with tempfile.TemporaryDirectory() as first, tempfile.TemporaryDirectory() as second:
|
||||||
|
config = Config(output_dir=Path(first), target_examples=150)
|
||||||
|
with patch.object(pipeline, 'make_episode', side_effect=interrupted):
|
||||||
|
with self.assertRaises(KeyboardInterrupt):
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
resumed = export(config)
|
||||||
|
clean = Config(output_dir=Path(second), target_examples=150)
|
||||||
|
asyncio.run(generate(clean))
|
||||||
|
uninterrupted = export(clean)
|
||||||
|
self.assertEqual(resumed['files'], uninterrupted['files'])
|
||||||
|
self.assertEqual(validate(config)['examples_checked'], 150)
|
||||||
|
|
||||||
|
def test_corrupted_shard_is_detected(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = Config(output_dir=Path(tmp), target_examples=4)
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
export(config)
|
||||||
|
next(Path(tmp).glob('sft/*/*/*.jsonl')).write_text('broken\n')
|
||||||
|
with self.assertRaisesRegex(ValueError, 'Повреждён'):
|
||||||
|
validate(config)
|
||||||
|
|
||||||
|
def test_all_steps_and_one_mode_obey_total(self):
|
||||||
|
for modes in (['step'], ['branch'], ['full', 'step', 'branch']):
|
||||||
|
with self.subTest(modes=modes), tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = Config(output_dir=Path(tmp), target_examples=37, modes=modes, step_sampling='all')
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
report = export(config)
|
||||||
|
self.assertEqual(report['examples'], 37)
|
||||||
|
self.assertEqual(validate(config)['examples_checked'], 37)
|
||||||
|
|
||||||
|
def test_smaller_export_does_not_remove_canonical_episodes(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = Config(output_dir=Path(tmp), target_examples=100)
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
original = export(config)
|
||||||
|
config.target_examples = 1
|
||||||
|
self.assertEqual(export(config)['examples'], 1)
|
||||||
|
config.target_examples = 100
|
||||||
|
self.assertEqual(export(config)['files'], original['files'])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from assembly_dataset.config import Config
|
||||||
|
from assembly_dataset.pipeline import generate, export, validate
|
||||||
|
|
||||||
|
|
||||||
|
class CsvExportTests(unittest.TestCase):
|
||||||
|
def test_each_shard_has_readable_csv_with_same_records(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = Config(output_dir=Path(tmp), target_examples=25, shard_size=2)
|
||||||
|
config.prompts.teacher_system = 'Правила'
|
||||||
|
config.prompts.training_system['full'] = 'Текст; с "кавычками"\nи новой строкой'
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
export(config)
|
||||||
|
for path in Path(tmp).glob('sft/*/*/*.jsonl'):
|
||||||
|
counterpart = path.with_suffix('.csv')
|
||||||
|
self.assertTrue(counterpart.exists())
|
||||||
|
self.assertTrue(counterpart.read_bytes().startswith(b'\xef\xbb\xbf'))
|
||||||
|
originals = [json.loads(line) for line in path.read_text().splitlines()]
|
||||||
|
with counterpart.open(encoding='utf-8-sig', newline='') as file:
|
||||||
|
rows = list(csv.DictReader(file, delimiter=';'))
|
||||||
|
self.assertEqual(len(rows), len(originals))
|
||||||
|
for original, row in zip(originals, rows):
|
||||||
|
self.assertEqual(row['ID записи'], original['record_id'])
|
||||||
|
self.assertEqual(row['Системная инструкция'], original['messages'][0]['content'])
|
||||||
|
self.assertEqual(row['План'], original['messages'][2]['content'])
|
||||||
|
self.assertNotRegex(row['План'], r'part_\d|slot_\d')
|
||||||
|
if original['output_format'] == 'text':
|
||||||
|
self.assertNotIn('"skill"', row['План'])
|
||||||
|
self.assertTrue(row['План'])
|
||||||
|
self.assertEqual(validate(config)['examples_checked'], 25)
|
||||||
|
|
||||||
|
def test_corrupt_csv_is_detected(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = Config(output_dir=Path(tmp), target_examples=1)
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
export(config)
|
||||||
|
files = list(Path(tmp).glob('sft/*/*/*.csv'))
|
||||||
|
self.assertEqual(len(files), 2)
|
||||||
|
files[0].write_text('broken')
|
||||||
|
with self.assertRaisesRegex(ValueError, 'Повреждён'):
|
||||||
|
validate(config)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from assembly_dataset.config import Config
|
||||||
|
from assembly_dataset.pipeline import generate, export, validate
|
||||||
|
from assembly_dataset.world import make_episode, training_rows
|
||||||
|
|
||||||
|
|
||||||
|
class PairedExportTests(unittest.TestCase):
|
||||||
|
def test_paired_shards_and_exact_count(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = Config(output_dir=Path(tmp), target_examples=10, shard_size=2)
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
report = export(config)
|
||||||
|
self.assertEqual(report['examples'], 10)
|
||||||
|
pairs = 0
|
||||||
|
for path in Path(tmp).glob('sft/*/*/[0-9]*.jsonl'):
|
||||||
|
if '.structured.' in path.name:
|
||||||
|
continue
|
||||||
|
structured_path = path.with_suffix('.structured.jsonl')
|
||||||
|
self.assertTrue(structured_path.exists())
|
||||||
|
text_rows = list(map(json.loads, path.read_text().splitlines()))
|
||||||
|
json_rows = list(map(json.loads, structured_path.read_text().splitlines()))
|
||||||
|
self.assertEqual(len(text_rows), len(json_rows))
|
||||||
|
for text, structured in zip(text_rows, json_rows):
|
||||||
|
self.assertEqual(text['record_id'], structured['record_id'])
|
||||||
|
self.assertEqual(text['messages'][1], structured['messages'][1])
|
||||||
|
self.assertNotEqual(text['messages'][0], structured['messages'][0])
|
||||||
|
answer = json.loads(structured['messages'][2]['content'])
|
||||||
|
self.assertEqual(set(answer), {'status', 'plan'})
|
||||||
|
self.assertNotRegex(str(answer), r'part_\d|slot_\d')
|
||||||
|
pairs += 1
|
||||||
|
self.assertEqual(pairs, 10)
|
||||||
|
self.assertEqual(validate(config)['examples_checked'], 10)
|
||||||
|
|
||||||
|
def test_structured_skills_and_branches(self):
|
||||||
|
config = Config(domains=['operator_assistance'], min_components=1, max_components=1,
|
||||||
|
blocked_fraction=0, step_sampling='all')
|
||||||
|
episode = make_episode(0, config)
|
||||||
|
rows = list(training_rows(episode, config, output_format='structured'))
|
||||||
|
full = json.loads(rows[0]['messages'][2]['content'])
|
||||||
|
actions = full['plan']
|
||||||
|
self.assertEqual(actions[2], {'skill': 'pick', 'object': 'распределительная коробка №1'})
|
||||||
|
self.assertEqual(actions[3]['target'], 'оператор')
|
||||||
|
self.assertEqual(actions[6]['target'], 'зона хранения №1')
|
||||||
|
self.assertNotIn('target', actions[7])
|
||||||
|
for row in rows[1:-1]:
|
||||||
|
self.assertEqual(len(json.loads(row['messages'][2]['content'])['plan']), 1)
|
||||||
|
branch = json.loads(rows[-1]['messages'][2]['content'])
|
||||||
|
for i, action in enumerate(branch['plan']):
|
||||||
|
self.assertEqual(action['on_failure'], 'replan_required')
|
||||||
|
self.assertEqual(action['on_success'], f'step_{i+2}' if i+1 < len(actions) else 'ready')
|
||||||
|
|
||||||
|
def test_blocked_empty_plan(self):
|
||||||
|
config = Config(domains=['operator_assistance'], min_components=1, max_components=1, blocked_fraction=1)
|
||||||
|
for row in training_rows(make_episode(0, config), config, output_format='structured'):
|
||||||
|
self.assertEqual(json.loads(row['messages'][2]['content']), {'status': 'blocked', 'plan': []})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from assembly_dataset.config import Config
|
||||||
|
from assembly_dataset.pipeline import generate, export
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressTests(unittest.TestCase):
|
||||||
|
def test_generation_export_and_resume_report_exact_total(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = Config(output_dir=Path(tmp), target_examples=7)
|
||||||
|
output, errors = StringIO(), StringIO()
|
||||||
|
with redirect_stdout(output), redirect_stderr(errors):
|
||||||
|
asyncio.run(generate(config, show_progress=True))
|
||||||
|
report = export(config, show_progress=True)
|
||||||
|
asyncio.run(generate(config, show_progress=True))
|
||||||
|
self.assertEqual(report['examples'], 7)
|
||||||
|
self.assertEqual(output.getvalue(), '')
|
||||||
|
text = errors.getvalue()
|
||||||
|
self.assertIn('Генерация', text)
|
||||||
|
self.assertIn('Экспорт', text)
|
||||||
|
self.assertIn('7/7', text.replace(' ', ''))
|
||||||
|
self.assertIn('100%', text)
|
||||||
|
|
||||||
|
def test_progress_disabled_is_silent(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = Config(output_dir=Path(tmp), target_examples=1)
|
||||||
|
errors = StringIO()
|
||||||
|
with redirect_stderr(errors):
|
||||||
|
asyncio.run(generate(config, show_progress=False))
|
||||||
|
export(config, show_progress=False)
|
||||||
|
self.assertEqual(errors.getvalue(), '')
|
||||||
|
|
||||||
|
def test_empty_dataset_progress(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = Config(output_dir=Path(tmp), target_examples=0)
|
||||||
|
errors = StringIO()
|
||||||
|
with redirect_stderr(errors):
|
||||||
|
asyncio.run(generate(config, show_progress=True))
|
||||||
|
export(config, show_progress=True)
|
||||||
|
self.assertIn('0/0', errors.getvalue().replace(' ', ''))
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from assembly_dataset.config import Config, load_config
|
||||||
|
from assembly_dataset.pipeline import generate
|
||||||
|
from assembly_dataset.world import make_episode, training_rows
|
||||||
|
from assembly_dataset.language import action_text
|
||||||
|
|
||||||
|
|
||||||
|
class PromptTests(unittest.TestCase):
|
||||||
|
def test_custom_request_and_retry_reach_teacher(self):
|
||||||
|
from assembly_dataset.teacher import enrich, Plan
|
||||||
|
from llm.config import OpenAiConfig
|
||||||
|
config = Config(blocked_fraction=0)
|
||||||
|
config.teacher.fraction = 1.0
|
||||||
|
config.teacher.llm = OpenAiConfig(provider='openai', model='test')
|
||||||
|
config.prompts.teacher_user = 'REQUEST ${payload}'
|
||||||
|
config.prompts.teacher_retry = 'REPAIR ${payload}'
|
||||||
|
episode = make_episode(0, config)
|
||||||
|
agent = SimpleNamespace(run=AsyncMock(side_effect=[
|
||||||
|
SimpleNamespace(output=Plan(status='ready', actions=[])),
|
||||||
|
SimpleNamespace(output=Plan.model_validate({**episode['plan'], 'actions': [{**a, 'text': action_text(a, episode['scene'], config.prompts)} for a in episode['plan']['actions']]})),
|
||||||
|
]))
|
||||||
|
result, error = asyncio.run(enrich(episode, config.teacher, agent, config.prompts))
|
||||||
|
self.assertIsNone(error)
|
||||||
|
first, second = [call.args[0] for call in agent.run.call_args_list]
|
||||||
|
self.assertTrue(first.startswith('REQUEST {'))
|
||||||
|
self.assertTrue(second.startswith('REPAIR {'))
|
||||||
|
self.assertIn('validation_error', json.loads(second.removeprefix('REPAIR ')))
|
||||||
|
self.assertEqual(result['source']['provider'], 'openai')
|
||||||
|
|
||||||
|
def test_external_prompts_relative_paths_and_export(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
prompts = yaml.safe_load(Path('prompts.yaml').read_text())
|
||||||
|
prompts['training_system']['full'] = 'Custom training rules'
|
||||||
|
prompts['training_user'] = 'Input: ${payload}'
|
||||||
|
prompts['task_instructions']['mechanical'] = 'Custom assembly task'
|
||||||
|
(root / 'texts.yaml').write_text(yaml.safe_dump(prompts))
|
||||||
|
(root / 'config.yaml').write_text('prompts_file: texts.yaml\noutput_dir: data\ndomains: [mechanical]\n')
|
||||||
|
config = load_config(root / 'config.yaml')
|
||||||
|
episode = make_episode(0, config)
|
||||||
|
record = next(training_rows(episode, config))
|
||||||
|
self.assertEqual(episode['instruction'], 'Custom assembly task')
|
||||||
|
self.assertEqual(record['messages'][0]['content'], 'Custom training rules')
|
||||||
|
self.assertTrue(record['messages'][1]['content'].startswith('Input: {'))
|
||||||
|
self.assertEqual(config.output_dir, root / 'data')
|
||||||
|
|
||||||
|
def test_prompt_changes_prevent_mixed_resume(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = Config(output_dir=Path(tmp), target_examples=1)
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
config.prompts.teacher_system = 'Different rules'
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
asyncio.run(generate(config))
|
||||||
|
|
||||||
|
def test_invalid_templates_are_rejected(self):
|
||||||
|
from assembly_dataset.prompts import Prompts
|
||||||
|
data = Config().prompts.model_dump()
|
||||||
|
for template in ('No scene', '${unknown}', '${payload.bad}'):
|
||||||
|
with self.subTest(template=template), self.assertRaises(ValueError):
|
||||||
|
Prompts.model_validate({**data, 'teacher_user': template})
|
||||||
|
|
||||||
|
def test_teacher_system_is_passed_to_agent(self):
|
||||||
|
from assembly_dataset.teacher import create_agent
|
||||||
|
from llm.config import OpenAiConfig
|
||||||
|
config = Config()
|
||||||
|
config.teacher.llm = OpenAiConfig(provider='openai', model='test')
|
||||||
|
config.prompts.teacher_system = 'Custom teacher instructions'
|
||||||
|
with patch('llm.factory.LLMModelProvider.create_from_config', new_callable=AsyncMock), patch('pydantic_ai.Agent') as agent:
|
||||||
|
asyncio.run(create_agent(config.teacher, config.prompts))
|
||||||
|
self.assertEqual(agent.call_args.kwargs['system_prompt'], 'Custom teacher instructions')
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from assembly_dataset.config import Config
|
||||||
|
from assembly_dataset.world import make_episode, training_rows, validate_plan
|
||||||
|
|
||||||
|
|
||||||
|
class RussianPlanTests(unittest.TestCase):
|
||||||
|
def test_training_answers_are_russian_text_for_all_modes(self):
|
||||||
|
config = Config(blocked_fraction=0)
|
||||||
|
episode = make_episode(0, config)
|
||||||
|
for row in training_rows(episode, config):
|
||||||
|
text = row['messages'][-1]['content']
|
||||||
|
self.assertRegex(text, '[А-Яа-я]')
|
||||||
|
self.assertNotRegex(text, r'part_\d|slot_\d|"skill"|on_success')
|
||||||
|
self.assertFalse(text.startswith('{'))
|
||||||
|
if row['mode'] == 'full':
|
||||||
|
self.assertTrue(text.startswith('1. '))
|
||||||
|
self.assertIn('Захватить', text)
|
||||||
|
|
||||||
|
def test_operator_assistance_holds_until_completion_then_returns_to_storage(self):
|
||||||
|
config = Config(domains=['operator_assistance'], min_components=1, max_components=1, blocked_fraction=0)
|
||||||
|
episode = make_episode(0, config)
|
||||||
|
skills = [a['skill'] for a in episode['plan']['actions']]
|
||||||
|
self.assertEqual(skills, ['detect', 'approach', 'pick', 'move_to_operator', 'hold',
|
||||||
|
'wait_assembly', 'move_to_storage', 'release', 'return_home'])
|
||||||
|
validate_plan(episode, episode['plan'])
|
||||||
|
text = next(training_rows(episode, config))['messages'][-1]['content']
|
||||||
|
self.assertIn('распределительная коробка', text)
|
||||||
|
self.assertIn('завершения сборки', text)
|
||||||
|
self.assertIn('хранения', text)
|
||||||
|
self.assertIn('исходное положение', text)
|
||||||
|
|
||||||
|
def test_teacher_wording_is_exported_without_replacing_it(self):
|
||||||
|
from assembly_dataset.teacher import Plan, enrich
|
||||||
|
from assembly_dataset.language import action_text
|
||||||
|
from llm.config import OpenAiConfig
|
||||||
|
config = Config(blocked_fraction=0)
|
||||||
|
config.teacher.fraction = 1
|
||||||
|
config.teacher.llm = OpenAiConfig(provider='openai', model='test')
|
||||||
|
episode = make_episode(0, config)
|
||||||
|
actions = [{**a, 'text': action_text(a, episode['scene'], config.prompts)} for a in episode['plan']['actions']]
|
||||||
|
actions[0]['text'] = 'Сначала ' + actions[0]['text'].lower()
|
||||||
|
output = Plan(status='ready', actions=actions)
|
||||||
|
class Agent:
|
||||||
|
async def run(self, *args, **kwargs):
|
||||||
|
return SimpleNamespace(output=output)
|
||||||
|
enriched, error = asyncio.run(enrich(episode, config.teacher, Agent(), config.prompts))
|
||||||
|
self.assertIsNone(error)
|
||||||
|
text = next(training_rows(enriched, config))['messages'][-1]['content']
|
||||||
|
self.assertIn(actions[0]['text'], text)
|
||||||
|
|
||||||
|
def test_teacher_cannot_supply_only_english_skill_text(self):
|
||||||
|
from assembly_dataset.teacher import Plan
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
Plan(status='ready', actions=[{'skill':'pick','object':'part_0','target':'slot_0','text':'pick: part_0 -> slot_0'}])
|
||||||
|
|
||||||
|
def test_russian_text_cannot_change_the_validated_action(self):
|
||||||
|
from assembly_dataset.language import action_text, validate_texts
|
||||||
|
config = Config(domains=['operator_assistance'], min_components=1, max_components=1, blocked_fraction=0)
|
||||||
|
episode = make_episode(0, config)
|
||||||
|
texts = [action_text(a, episode['scene'], config.prompts) for a in episode['plan']['actions']]
|
||||||
|
for bad in ('Бросить объект «распределительная коробка №1» на пол.',
|
||||||
|
texts[0] + ' Затем отпустить объект.'):
|
||||||
|
with self.subTest(text=bad), self.assertRaises(ValueError):
|
||||||
|
validate_texts(episode, [bad, *texts[1:]], config.prompts)
|
||||||
|
|
||||||
|
def test_approaching_another_object_cannot_interrupt_holding(self):
|
||||||
|
config = Config(domains=['operator_assistance'], min_components=2, max_components=2, blocked_fraction=0)
|
||||||
|
episode = make_episode(0, config)
|
||||||
|
actions = episode['plan']['actions']
|
||||||
|
# Execute the second object's approach while the first is being held.
|
||||||
|
reordered = actions[:5] + actions[9:11] + actions[5:9] + actions[11:]
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
validate_plan(episode, {'status': 'ready', 'actions': reordered})
|
||||||
@@ -826,20 +826,20 @@ name = "llm-robot-control"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy" },
|
|
||||||
{ name = "pandas" },
|
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-ai" },
|
{ name = "pydantic-ai" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "rich" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "numpy", specifier = ">=2.5.3" },
|
|
||||||
{ name = "pandas", specifier = ">=3.0.6" },
|
|
||||||
{ name = "pydantic", specifier = ">=2.13.5" },
|
{ name = "pydantic", specifier = ">=2.13.5" },
|
||||||
{ name = "pydantic-ai", specifier = ">=2.51.0" },
|
{ name = "pydantic-ai", specifier = ">=2.51.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.2.3" },
|
{ name = "python-dotenv", specifier = ">=1.2.3" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||||
|
{ name = "rich", specifier = ">=15.0.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -942,68 +942,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "numpy"
|
|
||||||
version = "2.5.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openai"
|
name = "openai"
|
||||||
version = "3.19.2"
|
version = "3.19.2"
|
||||||
@@ -1151,59 +1089,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pandas"
|
|
||||||
version = "3.0.6"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "numpy" },
|
|
||||||
{ name = "python-dateutil" },
|
|
||||||
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/1c/143605a1f6443ad50ebda78a31e5a3a10147fec2590e931584aaa5ff0a09/pandas-3.0.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ae8073aed8e21d1a7fe263dcdc6840743549722a6738198a0a46000fa9476f2", size = 10418900, upload-time = "2026-09-17T23:21:16.594Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/ca/87f8548f73d452aab35e4a90f8b39ae303295e0f2ef0b4055c44d6b3f1be/pandas-3.0.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:60d81f9e1799b36f3739e7fff44d1fbb2e8fd5a271b3863e03de9715fccda0fa", size = 10064785, upload-time = "2026-09-17T23:21:19.677Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/1a/d951442e5607c6e3b2462eff8f420797d428aa74b87c6ecfe4f48553626e/pandas-3.0.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:097090508a1dd335013d39106fc10b20f4fd4a171638e47b77d55798ed9dab6c", size = 10245290, upload-time = "2026-09-17T23:21:22.797Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/fa/96d50e1e6cd0b08b5e2b7c838f65ae644940f75a124063380b5ef73b6866/pandas-3.0.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e92d9fa834c7d877130027cddc0cad8dcff97c1f6cca26bd6310f847228b658", size = 10757657, upload-time = "2026-09-17T23:21:25.673Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/12/f82d13a2cb703e1a8acee7e01fdc2b898d9cd0c00f07d1dfce63af43e350/pandas-3.0.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b27c8d890e4aa2171437ae2a39de1d215e674158e4865c4023a8b31c932513b2", size = 11249114, upload-time = "2026-09-17T23:21:28.898Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/ce/8aef2e561a2f2c8b38c913c67373c65ba6748174e763d27c80271b24bd17/pandas-3.0.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8029ec0f1f89e4f985929ce1f6626dabf3140d61a4e9c1215afdab34eaf9a5d", size = 11820511, upload-time = "2026-09-17T23:21:32.11Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/bd/63cb67e6903ef6d9c2871916dbcbc09d254da0fe8b870cf62e16b21945f2/pandas-3.0.6-cp313-cp313-win_amd64.whl", hash = "sha256:f3ce8a6968045481e91a3990e797e348ce13db45ee164a7095bbc824e26c09dd", size = 9638092, upload-time = "2026-09-17T23:21:34.883Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/2e/e7b35b712edb068d382ddc8b2bea8a04974100515ba2daa22b478b265842/pandas-3.0.6-cp313-cp313-win_arm64.whl", hash = "sha256:cc39303913e2ea129915670de5d1c9fbd647f543bb72e5543bac8baa94e9e42f", size = 8952032, upload-time = "2026-09-17T23:21:37.729Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1", size = 10492213, upload-time = "2026-09-17T23:21:40.746Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729", size = 10156618, upload-time = "2026-09-17T23:21:44.142Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/f2/cc5f2adb8d6e86a85d9fb5128f8cf205a61189336f70d1f7faf0d1b53ec9/pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34", size = 10375479, upload-time = "2026-09-17T23:21:47.159Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1", size = 10783244, upload-time = "2026-09-17T23:21:50.367Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/5b/e150075b2c6eb69fae896f2d9239bc6ed07db97735971d53d66de6553460/pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de", size = 11382223, upload-time = "2026-09-17T23:21:53.355Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/8a/b441c587dc7355bf6e1f68a91b4f76a6c29740f0c23be3acc5d4ebbeea6d/pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c", size = 11854881, upload-time = "2026-09-17T23:21:56.342Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553", size = 9791672, upload-time = "2026-09-17T23:21:59.332Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/9e/db14c059c21f9baa1907d436f8bf30e0c76c6288225c5e8b79a08ba8b2c5/pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c", size = 9121246, upload-time = "2026-09-17T23:22:02.123Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/ba/bad0f8dac020ab38a8637fddab01a57a82da7a496a6e6f19590aad53ab62/pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514", size = 10920612, upload-time = "2026-09-17T23:22:05.404Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/a9/b500982e9aac6d52a58da4ad3f11e14168a315b06906b3f397c427878065/pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60", size = 10570213, upload-time = "2026-09-17T23:22:08.44Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/e6ecd0073c98be8f840ac3125b955272835d7d9fd69f5944383b164deb5e/pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541", size = 10252356, upload-time = "2026-09-17T23:22:11.302Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/f5/001e230a7a7803590d9275a1a3f7e1bb605e3a495cfe5e8d3a532090621b/pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965", size = 10655952, upload-time = "2026-09-17T23:22:14.283Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/ab/bab587148a3852c96aae26c4b5f9e04ce2221801ad94e166b4fbf969ede0/pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7", size = 11274177, upload-time = "2026-09-17T23:22:17.352Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/32/74b48d87df2b80892d713c149abfe36d5db4de41b4eccb042a2bc07dafc1/pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44", size = 11719041, upload-time = "2026-09-17T23:22:20.227Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/c6/d64b72d64d7eb0fad9fe424d34138e45dee70ddbea1360dcd0adf30e28f6/pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630", size = 10191388, upload-time = "2026-09-17T23:22:23.524Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/30/5e5b2ccabeca73ae2b03fc82bca3eabb7466cf43737f05ac08d591665d47/pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a", size = 9387796, upload-time = "2026-09-17T23:22:26.64Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/77/47c5fb0be8bdd00116814c2c40d9ec42dbeb943865ef95130fe58a898226/pandas-3.0.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:50c44cbf5820b6b91a5f74aae04972472aefadd3cd9fbd1010409d85528bd570", size = 10483960, upload-time = "2026-09-17T23:22:30.071Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/08/a310cb2fefe6d2b4623ab2150da818d93d4e73e33b58e4d163bb74243c5a/pandas-3.0.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:eb6900de08ac85f93ac4948aa6b80842eba555875337b8359035ac9c43e92d34", size = 10149432, upload-time = "2026-09-17T23:22:32.818Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/36/6af478ec3a26d7754555cd62c3101c589c0931b1d85398e4fa5403910a1c/pandas-3.0.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e25e2e1adee99ddfada6f7206a79ae8e9c8a8861b0e3eaaba165006d3eef18e", size = 10360981, upload-time = "2026-09-17T23:22:35.621Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/20/5ece0e9cb79a6473216620db6a90546f578001c2bd857b77c444b27acad1/pandas-3.0.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ff44b2cb51cbd691c91f92c4ea6c71e34003f239ebd67c2e857dc898466b49c", size = 10776585, upload-time = "2026-09-17T23:22:38.427Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/b6/cd3038f31ade5e8d2b4e1c9549d4b31e4d598469562f47142b2ad9171c0a/pandas-3.0.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5edd0a7abb0986ecce1ac81f56d99b6763f86aa6946dceb6c661224f90af5a19", size = 11385249, upload-time = "2026-09-17T23:22:41.18Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/87/05bb3003737f80375d7311774916a24d161e2e591abe8c672aed7813defc/pandas-3.0.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1bcb3e9ed29e74a7439cedff9e2aefd3ea65de84d7de9ccb6c194192541bd60e", size = 11848595, upload-time = "2026-09-17T23:22:44.207Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/30/1c0d46acf236d19ef975e9cdd5d1e0dd54924b03f0ed8287096ad4a18152/pandas-3.0.6-cp315-cp315-win_amd64.whl", hash = "sha256:253e12cb9081b0afbac607920f6142975966bc315135e09de275fdbaa415d2de", size = 9783810, upload-time = "2026-09-17T23:22:47.097Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/03/df3304a9c2833c4810e7f1c887b04105b24731f9deef8b5d5d04522a375b/pandas-3.0.6-cp315-cp315-win_arm64.whl", hash = "sha256:97274c9adf6255bb48c620cd6959805efa7f09ea2167f0e0ae006a448cd2fca7", size = 9116820, upload-time = "2026-09-17T23:22:50.149Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/25/d5f6cce5efa17c38e4f752a875b4a26d66cfadd529cb8c81672f0376d6a6/pandas-3.0.6-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:265f562fdd1079f69f3de96dd425c3405224038c0af4f920c54bd240ee2c4640", size = 10894614, upload-time = "2026-09-17T23:22:53.223Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/8b/e1876bfdc1df06bafc022d33202b5663bd86c81df2a6140aacafd0344669/pandas-3.0.6-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c6e4aae3e9bea26c6c9a20d88d96c86ec4a99b4db5fd516bcb4e829ab2c0ee36", size = 10546973, upload-time = "2026-09-17T23:22:56.155Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/27/e0a27a5a5c27f7db66657b44def9e93121fd0ad4f0808355b9898fcbf204/pandas-3.0.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a77a1a44e4d88f1c6a2a64d3eb12efec8420875722e14279800b173a7c7c2804", size = 10239717, upload-time = "2026-09-17T23:22:59.603Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/4f/4eadb7d86a921c1e8bc70916cfe601ed667c4169118d17d69958611c21f8/pandas-3.0.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86fa853a12e0b70927e2b1ee00d56d2224ec9cbb4b9d58348b5ad52d2f21150e", size = 10641275, upload-time = "2026-09-17T23:23:02.81Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/d1/eba72e9d905e84e79aefeefdcc6bc1abe15d9077973566643f4211636966/pandas-3.0.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c826e9babb7790142c399f58599d8de679bea059d7b39c5b6efa2096fac37266", size = 11262919, upload-time = "2026-09-17T23:23:06.038Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/8a/1c5bd2642b450e374b6191189f47c49538fe41f82348a66de6f647e6ab59/pandas-3.0.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8fe77b408d82e2615674dfed62533b95e18a03610573877422aada4f625d4947", size = 11709148, upload-time = "2026-09-17T23:23:09.082Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/3d/1b142bd0d0f1326a5d98c91c955b923cd1e06b5eecb428cd9d8817fa01c0/pandas-3.0.6-cp315-cp315t-win_amd64.whl", hash = "sha256:83e91d15738d7783c050197cef2f2cf82fc6353dae9865aa87ed1fa16aa4d55a", size = 10161518, upload-time = "2026-09-17T23:23:12.365Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/a3/6419c14da2adc1f09a6a183b8f91d7494d325b287f4ca984ac04f663638a/pandas-3.0.6-cp315-cp315t-win_arm64.whl", hash = "sha256:963ca21199097a84c7827c4678b04e30833084fbf8ef44fde3fa7180a29f8fa0", size = 9364529, upload-time = "2026-09-17T23:23:15.274Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "4.12.1"
|
version = "4.12.1"
|
||||||
@@ -1513,18 +1398,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "python-dateutil"
|
|
||||||
version = "2.9.0.post0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "six" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@@ -1827,15 +1700,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "six"
|
|
||||||
version = "1.17.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sniffio"
|
name = "sniffio"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -1956,15 +1820,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tzdata"
|
|
||||||
version = "2026.4"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user