From 1059b47978f7b4a60200422fff9abbdaf0fc65be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D0=B8=D0=B8=D0=BB=20=D0=93=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=B0=D1=80=D1=8C?= Date: Thu, 22 Oct 2026 10:07:05 +1000 Subject: [PATCH] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B4=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D1=81=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 386 +++++++++++++++++++++++++++++++ assembly_dataset/__init__.py | 1 + assembly_dataset/__main__.py | 51 ++++ assembly_dataset/config.py | 76 ++++++ assembly_dataset/csv_export.py | 30 +++ assembly_dataset/language.py | 114 +++++++++ assembly_dataset/pipeline.py | 247 ++++++++++++++++++++ assembly_dataset/progress.py | 45 ++++ assembly_dataset/prompts.py | 51 ++++ assembly_dataset/structured.py | 37 +++ assembly_dataset/teacher.py | 71 ++++++ assembly_dataset/world.py | 298 ++++++++++++++++++++++++ build-dataset/config.yml | 53 ----- build-dataset/robot-move-data.py | 116 ---------- config.yaml | 44 ++++ docs/skills.md | 112 +++++++++ llm/config.py | 31 +-- llm/factory.py | 69 +----- llm/schemas.py | 49 ---- prompts.yaml | 124 ++++++++++ pyproject.toml | 6 +- tests/test_assembly_dataset.py | 114 +++++++++ tests/test_assembly_pipeline.py | 113 +++++++++ tests/test_csv_export.py | 47 ++++ tests/test_paired_export.py | 60 +++++ tests/test_progress.py | 45 ++++ tests/test_prompts.py | 78 +++++++ tests/test_russian_plans.py | 77 ++++++ uv.lock | 153 +----------- 29 files changed, 2230 insertions(+), 468 deletions(-) create mode 100644 assembly_dataset/__init__.py create mode 100644 assembly_dataset/__main__.py create mode 100644 assembly_dataset/config.py create mode 100644 assembly_dataset/csv_export.py create mode 100644 assembly_dataset/language.py create mode 100644 assembly_dataset/pipeline.py create mode 100644 assembly_dataset/progress.py create mode 100644 assembly_dataset/prompts.py create mode 100644 assembly_dataset/structured.py create mode 100644 assembly_dataset/teacher.py create mode 100644 assembly_dataset/world.py delete mode 100644 build-dataset/config.yml delete mode 100644 build-dataset/robot-move-data.py create mode 100644 config.yaml create mode 100644 docs/skills.md delete mode 100644 llm/schemas.py create mode 100644 prompts.yaml create mode 100644 tests/test_assembly_dataset.py create mode 100644 tests/test_assembly_pipeline.py create mode 100644 tests/test_csv_export.py create mode 100644 tests/test_paired_export.py create mode 100644 tests/test_progress.py create mode 100644 tests/test_prompts.py create mode 100644 tests/test_russian_plans.py diff --git a/README.md b/README.md index e69de29..914db7b 100644 --- a/README.md +++ b/README.md @@ -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-запросов. diff --git a/assembly_dataset/__init__.py b/assembly_dataset/__init__.py new file mode 100644 index 0000000..a1ceee5 --- /dev/null +++ b/assembly_dataset/__init__.py @@ -0,0 +1 @@ +"""Reproducible, logically validated assembly planning datasets.""" diff --git a/assembly_dataset/__main__.py b/assembly_dataset/__main__.py new file mode 100644 index 0000000..483ab76 --- /dev/null +++ b/assembly_dataset/__main__.py @@ -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()) diff --git a/assembly_dataset/config.py b/assembly_dataset/config.py new file mode 100644 index 0000000..2c0978d --- /dev/null +++ b/assembly_dataset/config.py @@ -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 diff --git a/assembly_dataset/csv_export.py b/assembly_dataset/csv_export.py new file mode 100644 index 0000000..f695448 --- /dev/null +++ b/assembly_dataset/csv_export.py @@ -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'], + } diff --git a/assembly_dataset/language.py b/assembly_dataset/language.py new file mode 100644 index 0000000..1e886c7 --- /dev/null +++ b/assembly_dataset/language.py @@ -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) diff --git a/assembly_dataset/pipeline.py b/assembly_dataset/pipeline.py new file mode 100644 index 0000000..c38d45d --- /dev/null +++ b/assembly_dataset/pipeline.py @@ -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} diff --git a/assembly_dataset/progress.py b/assembly_dataset/progress.py new file mode 100644 index 0000000..b927fd0 --- /dev/null +++ b/assembly_dataset/progress.py @@ -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() diff --git a/assembly_dataset/prompts.py b/assembly_dataset/prompts.py new file mode 100644 index 0000000..8d9fc83 --- /dev/null +++ b/assembly_dataset/prompts.py @@ -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) diff --git a/assembly_dataset/structured.py b/assembly_dataset/structured.py new file mode 100644 index 0000000..8921e82 --- /dev/null +++ b/assembly_dataset/structured.py @@ -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} diff --git a/assembly_dataset/teacher.py b/assembly_dataset/teacher.py new file mode 100644 index 0000000..0e48e1c --- /dev/null +++ b/assembly_dataset/teacher.py @@ -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 diff --git a/assembly_dataset/world.py b/assembly_dataset/world.py new file mode 100644 index 0000000..db175c6 --- /dev/null +++ b/assembly_dataset/world.py @@ -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)}, + ]} diff --git a/build-dataset/config.yml b/build-dataset/config.yml deleted file mode 100644 index e740778..0000000 --- a/build-dataset/config.yml +++ /dev/null @@ -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} diff --git a/build-dataset/robot-move-data.py b/build-dataset/robot-move-data.py deleted file mode 100644 index 641bbb2..0000000 --- a/build-dataset/robot-move-data.py +++ /dev/null @@ -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()) \ No newline at end of file diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..69e03eb --- /dev/null +++ b/config.yaml @@ -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 diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 0000000..6570b87 --- /dev/null +++ b/docs/skills.md @@ -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`. + diff --git a/llm/config.py b/llm/config.py index d194454..5a7166f 100644 --- a/llm/config.py +++ b/llm/config.py @@ -1,7 +1,6 @@ from pathlib import Path from typing import Annotated, Literal -import yaml from pydantic import BaseModel, ConfigDict, Field @@ -31,36 +30,8 @@ LLMConfig = Annotated[ # Generation class GenerationConfig(ConfigModel): - temperature: float = Field(default=.7, + temperature: float | None = Field(default=None, ge=.0, le=2.0) max_tokens: int = Field(default=4096, 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) \ No newline at end of file diff --git a/llm/factory.py b/llm/factory.py index b79cc9f..cfb32a7 100644 --- a/llm/factory.py +++ b/llm/factory.py @@ -1,6 +1,5 @@ import os import webbrowser -import pandas as pd from pathlib import Path 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 .credentials import FileCodexCredentionalSource -from .schemas import BaseDatasetRecord, ChatRecord, ChatMessage class LLMModelProvider: @@ -22,7 +20,7 @@ class LLMModelProvider: def create_openai_compatible(model_name: str, api_key: str, base_url: str - ) -> OpenAICodexModel: + ) -> OpenAIChatModel: provider = OpenAIProvider(base_url=base_url, api_key=api_key) @@ -84,68 +82,3 @@ class LLMModelProvider: ) 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") \ No newline at end of file diff --git a/llm/schemas.py b/llm/schemas.py deleted file mode 100644 index a7a90b4..0000000 --- a/llm/schemas.py +++ /dev/null @@ -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 diff --git a/prompts.yaml b/prompts.yaml new file mode 100644 index 0000000..7547cd1 --- /dev/null +++ b/prompts.yaml @@ -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. diff --git a/pyproject.toml b/pyproject.toml index e387290..9c6a7b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "llm-robot-control" version = "0.1.0" -description = "Add your description here" +description = "Synthetic datasets for high-level industrial assembly planning" readme = "README.md" requires-python = ">=3.13" dependencies = [ - "numpy>=2.5.3", - "pandas>=3.0.6", "pydantic>=2.13.5", "pydantic-ai>=2.51.0", "python-dotenv>=1.2.3", + "pyyaml>=6.0.3", + "rich>=15.0.0", ] diff --git a/tests/test_assembly_dataset.py b/tests/test_assembly_dataset.py new file mode 100644 index 0000000..54797fb --- /dev/null +++ b/tests/test_assembly_dataset.py @@ -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() diff --git a/tests/test_assembly_pipeline.py b/tests/test_assembly_pipeline.py new file mode 100644 index 0000000..8a1cd09 --- /dev/null +++ b/tests/test_assembly_pipeline.py @@ -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() diff --git a/tests/test_csv_export.py b/tests/test_csv_export.py new file mode 100644 index 0000000..2bea7cb --- /dev/null +++ b/tests/test_csv_export.py @@ -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) diff --git a/tests/test_paired_export.py b/tests/test_paired_export.py new file mode 100644 index 0000000..2897461 --- /dev/null +++ b/tests/test_paired_export.py @@ -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': []}) diff --git a/tests/test_progress.py b/tests/test_progress.py new file mode 100644 index 0000000..03bc439 --- /dev/null +++ b/tests/test_progress.py @@ -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(' ', '')) diff --git a/tests/test_prompts.py b/tests/test_prompts.py new file mode 100644 index 0000000..fd54e9b --- /dev/null +++ b/tests/test_prompts.py @@ -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') diff --git a/tests/test_russian_plans.py b/tests/test_russian_plans.py new file mode 100644 index 0000000..d6548b8 --- /dev/null +++ b/tests/test_russian_plans.py @@ -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}) diff --git a/uv.lock b/uv.lock index 20e0a0b..7080188 100644 --- a/uv.lock +++ b/uv.lock @@ -826,20 +826,20 @@ name = "llm-robot-control" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "numpy" }, - { name = "pandas" }, { name = "pydantic" }, { name = "pydantic-ai" }, { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "rich" }, ] [package.metadata] requires-dist = [ - { name = "numpy", specifier = ">=2.5.3" }, - { name = "pandas", specifier = ">=3.0.6" }, { name = "pydantic", specifier = ">=2.13.5" }, { name = "pydantic-ai", specifier = ">=2.51.0" }, { name = "python-dotenv", specifier = ">=1.2.3" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "rich", specifier = ">=15.0.0" }, ] [[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" }, ] -[[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]] name = "openai" 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" }, ] -[[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]] name = "platformdirs" 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" }, ] -[[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]] name = "python-dotenv" 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" }, ] -[[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]] name = "sniffio" 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" }, ] -[[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]] name = "urllib3" version = "2.8.0"