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

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
+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)},
]}