обновлен формат генерации датасета

This commit is contained in:
Даниил Грабарь
2026-10-22 10:07:05 +10:00
parent 9e551a2ca9
commit 1059b47978
29 changed files with 2230 additions and 468 deletions
+386
View File
@@ -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-запросов.
+1
View File
@@ -0,0 +1 @@
"""Reproducible, logically validated assembly planning datasets."""
+51
View File
@@ -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())
+76
View File
@@ -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
+30
View File
@@ -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'],
}
+114
View File
@@ -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)
+247
View File
@@ -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}
+45
View File
@@ -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()
+51
View File
@@ -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)
+37
View File
@@ -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}
+71
View File
@@ -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
+298
View File
@@ -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)},
]}
-53
View File
@@ -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}
-116
View File
@@ -1,116 +0,0 @@
import asyncio
from dotenv import load_dotenv
# from pydantic import BaseModel
from pydantic_ai import Agent
from pydantic_ai.settings import ModelSettings
from llm.config import load_config
from llm.factory import LLMModelProvider, DatasetWriter
from llm.schemas import (RobotPlan, RobotDatasetRecord,
Prompts, ModelResponse,
ModelInfo, GenerationSettings)
load_dotenv()
def format_robot_plan(plan: RobotPlan):
return "\n".join(
f"{step}"
for step in plan.steps
)
async def generate_record(task: str,
agent: Agent,
settings: ModelSettings,
config,
semaphore: asyncio.Semaphore) -> RobotDatasetRecord:
async with semaphore:
user_prompt = config.prompts.user.format(task=task)
result = await agent.run(user_prompt,
model_settings=settings)
assistant_content = format_robot_plan(result.output)
return RobotDatasetRecord(
task=task,
prompts=Prompts(
system_prompt=config.prompts.system,
instructions=config.prompts.instructions,
user_prompt=user_prompt
),
response=ModelResponse(
model_response=result.output.model_dump_json(),
assistant_content=assistant_content
),
model_info=ModelInfo(
provider=config.llm.provider,
model=config.llm.model
),
generation=GenerationSettings(
temperature=config.generation.temperature,
max_tokens=config.generation.max_tokens
)
)
async def main():
config = load_config("./build-dataset/config.yml")
model = await LLMModelProvider.create_from_config(config.llm)
# model = await LLMModelProvider.create_codex(model_name='gpt-5.6-luna')
# model = LLMModelProvider.create_openai_compatible(model_name="qwen/qwen3-8b",
# api_key=os.getenv("API_KEY"),
# base_url=os.getenv("BASE_URL"))
agent = Agent(model=model,
output_type=RobotPlan,
system_prompt=config.prompts.system,
instructions=config.prompts.instructions)
settings = ModelSettings(
**config.generation.model_dump()
)
tasks = [
(
"Перемести деталь A из точки PICK_A в точку PLACE_A. "
"Безопасная высота подъема — 200 мм."
),
(
"Перемести деталь B из точки PICK_B в точку PLACE_B. "
"Безопасная высота подъема — 150 мм."
),
(
"Возьми крышку из точки COVER_PICK и перемести "
"ее в точку COVER_PLACE."
),
]
semaphore = asyncio.Semaphore(config.runtime.concurrency)
records = await asyncio.gather(*[
generate_record(
task,
agent,
settings,
config,
semaphore,
)
for task in tasks
])
for record in records:
dataset = DatasetWriter(
dataset_record=record,
base_path="./dataset"
)
dataset.to_csv(filename="robot-plan.csv")
dataset.to_jsonl(filename="robotplan.jsonl")
if __name__ == "__main__":
asyncio.run(main())
+44
View File
@@ -0,0 +1,44 @@
# Число итоговых обучающих строк СУММАРНО по full/step/branch.
# CLI --count имеет приоритет. Можно указать 0, 1, 100, 500000 и т.д.
target_examples: 500000
seed: 20261021
output_dir: dataset/assembly-ru
prompts_file: prompts.yaml # инструкции LLM и обучающих записей
modes: [full, step, branch]
step_sampling: one # one: один шаг на эпизод; all: каждый шаг отдельной строкой
shard_size: 1000
split: [1.0, 0.0, 0.0] # разделение по сценарию; малые наборы не гарантируют все split
min_components: 1
max_components: 10
blocked_fraction: 0.2
domains:
- manipulation
- mechanical
- fastening
- press_fit
- electrical
- electronics
- adhesive
- welding
- collaborative
- inspection
- operator_assistance
teacher:
enabled: true # false: полностью локальная генерация без API
fraction: 1.0
concurrency: 4
timeout_seconds: 180
retries: 2
# llm:
# provider: openai
# model: your-provider/your-model
# api_key_env: API_KEY
# base_url_env: BASE_URL
# Для существующей интеграции Codex замените llm выше:
llm:
provider: codex
model: gpt-6-astra
credential_path: .config/codex-credentials.json
generation:
temperature: null
max_tokens: 8192
+112
View File
@@ -0,0 +1,112 @@
# Навыки робота
Справочник 32 навыков, используемых генератором датасета.
## Формат действия
```json
{"skill": "align", "object": "деталь №1", "target": "сборочная позиция №1"}
```
- `skill` — фиксированное имя навыка на английском; по нему программа выбирает обработчик.
- `object` — русское название объекта из сцены. Для `return_home` — `робот`.
- `target` — русское название цели действия. Поле присутствует только у навыков, отмеченных в таблице.
## Список навыков
| Навык | Назначение | Значение `target` |
|---|---|---|
| `detect` | Определить положение объекта | — |
| `approach` | Подвести робот к объекту | — |
| `pick` | Захватить объект | — |
| `position` | Переместить объект к месту назначения | Место назначения |
| `place` | Установить объект на место, сохраняя захват | Место установки |
| `align` | Совместить объект с местом установки | Место установки |
| `insert` | Вставить объект | Место установки |
| `release` | Отпустить объект | — |
| `inspect` | Проверить результат установки объекта | — |
| `insert_fastener` | Установить крепёж в отверстие | Место установки |
| `tighten` | Затянуть крепёж | Место установки |
| `press_fit` | Запрессовать объект | Место установки |
| `route_wire` | Проложить провод | Место подключения |
| `connect` | Подключить провод | Место подключения |
| `test_continuity` | Проверить электрическую целостность соединения провода | — |
| `seat_component` | Установить электронный компонент | Место установки |
| `test_connection` | Проверить подключение электронного компонента | — |
| `clean_surface` | Очистить поверхность объекта для склеивания | — |
| `apply_adhesive` | Нанести клей на объект | — |
| `bond` | Прижать объект к месту соединения | Место соединения |
| `wait_cure` | Дождаться отверждения клея на соединении | — |
| `clamp` | Зафиксировать объект в приспособлении | Место фиксации |
| `weld` | Выполнить сварное соединение | Место соединения |
| `measure` | Проверить размеры объекта | — |
| `wait_operator` | Дождаться готовности оператора принять объект | — |
| `handover` | Поднести объект для передачи оператору, сохраняя захват | `оператор` |
| `confirm_assembly` | Получить подтверждение сборки объекта от оператора | — |
| `move_to_operator` | Переместить объект к оператору | `оператор` |
| `hold` | Удерживать объект в неподвижном состоянии возле оператора | `оператор` |
| `wait_assembly` | Дождаться подтверждения завершения сборки, продолжая удерживать объект | — |
| `move_to_storage` | Переместить объект в зону хранения | Зона хранения |
| `return_home` | Вернуться в исходное положение | `исходное положение` |
## Форматы ответа модели
### Полный план — `full`
Ответ содержит `status` и массив `plan`:
```json
{
"status": "ready",
"plan": [
{"skill": "detect", "object": "распределительная коробка №1"},
{"skill": "approach", "object": "распределительная коробка №1"},
{"skill": "pick", "object": "распределительная коробка №1"},
{"skill": "move_to_operator", "object": "распределительная коробка №1", "target": "оператор"},
{"skill": "hold", "object": "распределительная коробка №1", "target": "оператор"},
{"skill": "wait_assembly", "object": "распределительная коробка №1"},
{"skill": "move_to_storage", "object": "распределительная коробка №1", "target": "зона хранения №1"},
{"skill": "release", "object": "распределительная коробка №1"},
{"skill": "return_home", "object": "робот", "target": "исходное положение"}
]
}
```
- `ready` — полный план достигает цели в логической модели сцены.
- `blocked` — цель недостижима в текущей сцене. План может содержать выполнимую часть действий; если действий нет, массив пуст.
### Следующее действие — `step`
```json
{
"status": "ready",
"plan": [{"skill": "pick", "object": "деталь №1"}]
}
```
В этом режиме `ready` означает доступность указанного следующего действия, а не гарантию завершения всей задачи. При достигнутой цели возвращается `ready` с пустым массивом; при невозможности продолжения — `blocked` с пустым массивом.
### План с ветвлением — `branch`
У каждого действия дополнительно присутствуют:
| Поле | Назначение |
|---|---|
| `id` | Идентификатор действия: `step_1`, `step_2` и далее |
| `on_success` | ID следующего действия или терминальный исход `ready` / `blocked` |
| `on_failure` | Терминальный исход `replan_required` |
Пример отдельного узла внутри плана:
```json
{
"id": "step_1",
"skill": "pick",
"object": "деталь №1",
"on_success": "step_2",
"on_failure": "replan_required"
}
```
Выполнение начинается с первого элемента `plan`. При успехе последнего действия переход ведёт к `ready` или `blocked`. При ошибке `replan_required` означает остановку, обновление сцены и построение нового плана. Эти три исхода не являются навыками. У пустого плана итог определяется полем `status`.
+1 -30
View File
@@ -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)
+1 -68
View File
@@ -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")
-49
View File
@@ -1,49 +0,0 @@
from typing import Literal, Optional
from pydantic import BaseModel, Field
class RobotPlan(BaseModel):
steps: list[str] = Field(
description="Последовательность высокоуровневых действий робота",
min_length=1
)
class ChatMessage(BaseModel):
role: Literal["system",
"user",
"assistant"]
content: str
class ChatRecord(BaseModel):
messages: list[ChatMessage]
class Prompts(BaseModel):
system_prompt: str
instructions: str
user_prompt: str
class ModelResponse(BaseModel):
model_response: str
assistant_content: Optional[str] = None
class ModelInfo(BaseModel):
provider: str
model: str
class GenerationSettings(BaseModel):
temperature: float
max_tokens: int
class BaseDatasetRecord(BaseModel):
prompts: Prompts
response: ModelResponse
model_info: ModelInfo
generation: GenerationSettings
class RobotDatasetRecord(BaseDatasetRecord):
task: str
+124
View File
@@ -0,0 +1,124 @@
# Инструкции и тексты планов. В шаблонах user/retry сохраните ${payload}.
# action_templates: {object} — название объекта, {target} — место назначения.
teacher_system: |-
Составь последовательность действий робота на русском языке по команде и сцене.
Это высокоуровневый план для человека, а не программа управления: без Python,
ROS, координат приводов и записей вида pick: part_0 -> slot_0.
Для каждого действия верни text — самостоятельную русскую фразу с названием
объекта из scene.components[].name. Например: «Захватить объект „распределительная
коробка №1“», «Переместить объект „распределительная коробка №1“ к оператору», «Дождаться завершения сборки».
Одновременно укажи skill, object и target из доступных операций: эти поля нужны
только внутреннему валидатору и не попадут в обучающий текст ответа.
Текст должен точно описывать выбранную операцию, без дополнительных действий.
Соблюдай requires/forbids и эффекты add/delete. ready означает достижение goal;
blocked допустим только если цель недостижима. Не меняй постановку задачи.
Не отпускай удерживаемый объект до завершения работы, если задача требует удержания.
Каждая операция содержит reference_text — проверенное русское описание.
Используй его в text. Допускаются только изменения пунктуации, вводные
«Сначала», «Затем», «Далее» и замены: «переместить»/«перенести»,
«захватить»/«взять», «проверить»/«проконтролировать»,
«вернуться»/«возвратиться», «положение»/«местоположение».
Не добавляй действия, отрицания или условия, отсутствующие в reference_text.
teacher_user: ${payload}
teacher_retry: |-
Исправь план с учётом ошибки проверки.
${payload}
training_system:
full: |-
Составь полную последовательность действий робота на русском языке по заданию и сцене. Ответ — нумерованный список понятных действий с названиями объектов. Учитывай исходное состояние, зависимости и условия ожидания. Не пиши JSON, программный код или технические имена навыков. Если задача невыполнима, явно сообщи об этом.
step: |-
Укажи одно следующее действие робота на русском языке с учётом задания, текущей сцены и истории. Используй названия объектов. Не повторяй уже выполненные действия. Не пиши JSON или программный код. Если продолжить невозможно, сообщи об этом.
branch: |-
Составь план действий робота на русском языке с условиями продолжения: что делать при успехе и при ошибке. Используй нумерованные пункты и понятные названия объектов. При сбое остановись и запроси перепланирование по обновлённой сцене. Не пиши JSON, программный код или технические идентификаторы узлов.
training_user: ${payload}
task_instructions:
manipulation: |-
Перемести детали в сборочные позиции. Соблюдай зависимости деталей и проверь результат
каждой операции.
mechanical: Собери механический узел. Соблюдай зависимости деталей и проверь результат каждой операции.
fastening: Установи и затяни крепёж. Соблюдай зависимости деталей и проверь результат каждой операции.
press_fit: Собери прессовые соединения. Соблюдай зависимости деталей и проверь результат каждой операции.
electrical: Выполни электромонтаж. Соблюдай зависимости деталей и проверь результат каждой операции.
electronics: Установи электронные компоненты. Соблюдай зависимости деталей и проверь результат каждой операции.
adhesive: Собери клеевые соединения. Соблюдай зависимости деталей и проверь результат каждой операции.
welding: Соедини детали сваркой. Соблюдай зависимости деталей и проверь результат каждой операции.
collaborative: |-
Передай детали оператору для сборки. Соблюдай зависимости деталей и проверь результат каждой
операции.
inspection: |-
Проверь детали в контрольной позиции. Соблюдай зависимости деталей и проверь результат
каждой операции.
operator_assistance: |-
Удерживай распределительную коробку возле оператора, пока он устанавливает элементы. Когда сборка закончится, убери коробку на место хранения и вернись в исходное положение.
object_names:
manipulation: деталь
mechanical: деталь
fastening: крепёж
press_fit: деталь
electrical: провод
electronics: электронный компонент
adhesive: деталь
welding: деталь
collaborative: деталь
inspection: деталь
operator_assistance: распределительная коробка
action_templates:
detect: Определить положение объекта «{object}».
approach: Переместиться к объекту «{object}».
pick: Захватить объект «{object}».
position: Переместить объект «{object}» к месту «{target}».
place: Установить объект «{object}» в место «{target}».
release: Отпустить объект «{object}».
inspect: Проверить результат установки объекта «{object}».
align: Совместить объект «{object}» с местом установки «{target}».
insert: Вставить объект «{object}» в место «{target}».
insert_fastener: Установить объект «{object}» в крепёжное отверстие «{target}».
tighten: Затянуть крепёж «{object}» в месте «{target}».
press_fit: Запрессовать объект «{object}» в место «{target}».
route_wire: Проложить провод «{object}» к месту подключения «{target}».
connect: Подключить провод «{object}» в месте «{target}».
test_continuity: Проверить электрическую целостность соединения провода «{object}».
seat_component: Установить электронный компонент «{object}» в место «{target}».
test_connection: Проверить подключение компонента «{object}».
clean_surface: Очистить поверхность объекта «{object}» для склеивания.
apply_adhesive: Нанести клеевой состав на объект «{object}».
bond: Прижать объект «{object}» к месту соединения «{target}».
wait_cure: Дождаться отверждения клея на соединении объекта «{object}».
clamp: Зафиксировать объект «{object}» в приспособлении «{target}».
weld: Выполнить сварное соединение объекта «{object}» в месте «{target}».
wait_operator: Дождаться готовности оператора принять объект «{object}».
handover: Передать объект «{object}» оператору, сохраняя захват до отпускания.
confirm_assembly: Получить подтверждение сборки объекта «{object}» от оператора.
measure: Проверить размеры объекта «{object}».
move_to_operator: Переместить объект «{object}» к оператору.
hold: Удерживать объект «{object}» в неподвижном состоянии возле оператора.
wait_assembly: Дождаться подтверждения завершения сборки объекта «{object}», продолжая удерживать его.
move_to_storage: Переместить объект «{object}» в место «{target}».
return_home: Вернуться в исходное положение.
# Инструкции для второго JSONL. Названия навыков остаются английскими.
structured_system:
full: |-
Составь полный план по заданию и сцене. Верни только JSON с полями status и plan.
status: ready, если цель достижима; blocked, если задача невыполнима.
plan — массив действий с skill, object и, когда требуется место назначения, target.
skill — имя навыка на английском; object и target — русские названия из сцены.
Для pick, release и inspect не добавляй target. Не используй технические ID объектов.
При blocked сохрани выполнимую часть плана; если действий нет, верни пустой массив.
step: |-
Укажи следующее действие с учётом текущей сцены и истории. Верни только JSON
с полями status и plan. Если действие доступно, status — ready, plan содержит
ровно одно действие с skill, object и необязательным target. Если цель уже
достигнута, верни ready и пустой plan; если продолжить невозможно — blocked
и пустой plan. skill — имя навыка на английском, object и target — русские
названия. Для pick, release и inspect target не нужен. Не повторяй историю.
branch: |-
Составь план с ветвлением. Верни только JSON с полями status и plan.
status — ready или blocked для полного сценария. plan — массив действий:
skill, object, необязательный target, id, on_success, on_failure.
skill — имя навыка на английском; object и target — русские названия.
id имеет вид step_1, step_2 и далее. Выполнение начинается с первого элемента.
on_success указывает следующий id, а в последнем действии — ready или blocked.
on_failure всегда replan_required: остановиться, обновить сцену и перепланировать.
ready, blocked и replan_required — терминальные исходы, а не ID действий.
Пустой plan означает отсутствие действий; итог задан status.
+3 -3
View File
@@ -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",
]
+114
View File
@@ -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()
+113
View File
@@ -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()
+47
View File
@@ -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)
+60
View File
@@ -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': []})
+45
View File
@@ -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(' ', ''))
+78
View File
@@ -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')
+77
View File
@@ -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})
Generated
+4 -149
View File
@@ -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"