обновлен формат генерации датасета
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""Behavioral tests for exact sizes, restart safety and logical plan validation."""
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class AssemblyTests(unittest.TestCase):
|
||||
def test_preinstalled_parts_do_not_depend_on_unfinished_assembly(self):
|
||||
from assembly_dataset.world import make_episode, RECIPES
|
||||
from assembly_dataset.config import Config
|
||||
for index in range(100):
|
||||
episode = make_episode(index, Config())
|
||||
scene = episode['scene']
|
||||
last = len(RECIPES[scene['domain']][1]) - 1
|
||||
for component in scene['components']:
|
||||
if f"{component['id']}:stage:{last}" in scene['initial_state']:
|
||||
self.assertEqual(component['dependencies'], [])
|
||||
|
||||
def test_exact_counts_resume_and_split_grouping(self):
|
||||
from assembly_dataset.config import Config
|
||||
from assembly_dataset.pipeline import generate, export
|
||||
for count in (0, 1, 2, 7, 100):
|
||||
with self.subTest(count=count), tempfile.TemporaryDirectory() as tmp:
|
||||
config = Config(output_dir=Path(tmp), target_examples=count, shard_size=11)
|
||||
asyncio.run(generate(config))
|
||||
export(config)
|
||||
rows = [json.loads(line) for p in Path(tmp).glob('sft/*/*/[0-9][0-9][0-9][0-9][0-9][0-9].jsonl')
|
||||
for line in p.read_text().splitlines()]
|
||||
self.assertEqual(len(rows), count)
|
||||
self.assertEqual(len({r['record_id'] for r in rows}), count)
|
||||
groups = {}
|
||||
for r in rows:
|
||||
self.assertEqual(groups.setdefault(r['scenario_id'], r['split']), r['split'])
|
||||
asyncio.run(generate(config))
|
||||
export(config)
|
||||
self.assertEqual(sum(len(p.read_text().splitlines()) for p in Path(tmp).glob('sft/*/*/[0-9][0-9][0-9][0-9][0-9][0-9].jsonl')), count)
|
||||
config.target_examples = count + 4
|
||||
asyncio.run(generate(config))
|
||||
export(config)
|
||||
self.assertEqual(sum(len(p.read_text().splitlines()) for p in Path(tmp).glob('sft/*/*/[0-9][0-9][0-9][0-9][0-9][0-9].jsonl')), count + 4)
|
||||
|
||||
def test_rejects_changed_generation_settings_on_resume(self):
|
||||
from assembly_dataset.config import Config
|
||||
from assembly_dataset.pipeline import generate
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = Config(output_dir=Path(tmp), target_examples=4)
|
||||
asyncio.run(generate(config))
|
||||
config.seed += 1
|
||||
with self.assertRaisesRegex(ValueError, 'config|конфиг'):
|
||||
asyncio.run(generate(config))
|
||||
|
||||
def test_plans_and_branches_are_executable(self):
|
||||
from assembly_dataset.world import make_episode, validate_plan, validate_branch
|
||||
from assembly_dataset.config import Config
|
||||
for index in range(100):
|
||||
episode = make_episode(index, Config())
|
||||
validate_plan(episode, episode['plan'])
|
||||
validate_branch(episode, episode['branch'])
|
||||
if episode['plan']['actions']:
|
||||
invalid = copy.deepcopy(episode['plan'])
|
||||
invalid['actions'][0]['object'] = 'does_not_exist'
|
||||
with self.assertRaises(ValueError):
|
||||
validate_plan(episode, invalid)
|
||||
invalid_branch = copy.deepcopy(episode['branch'])
|
||||
invalid_branch['nodes'][0]['on_failure'] = 'missing_node'
|
||||
with self.assertRaises(ValueError):
|
||||
validate_branch(episode, invalid_branch)
|
||||
|
||||
def test_missing_goal_and_cycles_are_rejected(self):
|
||||
from assembly_dataset.world import make_episode, validate_plan, validate_branch
|
||||
from assembly_dataset.config import Config
|
||||
episode = next(e for i in range(100) if (e := make_episode(i, Config()))['plan']['status'] == 'ready')
|
||||
broken = copy.deepcopy(episode['plan'])
|
||||
broken['actions'] = []
|
||||
with self.assertRaises(ValueError):
|
||||
validate_plan(episode, broken)
|
||||
branch = copy.deepcopy(episode['branch'])
|
||||
branch['nodes'][0]['on_success'] = branch['nodes'][0]['id']
|
||||
with self.assertRaises(ValueError):
|
||||
validate_branch(episode, branch)
|
||||
|
||||
def test_failure_branch_requires_fresh_scene_before_another_action(self):
|
||||
from assembly_dataset.world import make_episode, validate_branch
|
||||
from assembly_dataset.config import Config
|
||||
episode = make_episode(0, Config(blocked_fraction=0))
|
||||
branch = copy.deepcopy(episode['branch'])
|
||||
retry = copy.deepcopy(branch['nodes'][0])
|
||||
retry['id'] = 'retry'
|
||||
branch['nodes'][0]['on_failure'] = 'retry'
|
||||
branch['nodes'].append(retry)
|
||||
with self.assertRaises(ValueError):
|
||||
validate_branch(episode, branch)
|
||||
|
||||
def test_reproducibility_and_structural_diversity(self):
|
||||
from assembly_dataset.world import make_episode
|
||||
from assembly_dataset.config import Config
|
||||
config = Config()
|
||||
signatures = {make_episode(i, config)['signature'] for i in range(1000)}
|
||||
self.assertGreater(len(signatures), 990)
|
||||
self.assertEqual(make_episode(12, config), make_episode(12, config))
|
||||
|
||||
def test_config_rejects_invalid_counts_modes_and_splits(self):
|
||||
from assembly_dataset.config import Config
|
||||
for kwargs in ({'target_examples': -1}, {'target_examples': 1.5}, {'modes': []},
|
||||
{'modes': ['full', 'full']}, {'split': [0.8, 0.5, 0.1]}):
|
||||
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
|
||||
Config(**kwargs)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,113 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from assembly_dataset.config import Config, TeacherConfig
|
||||
from assembly_dataset.pipeline import generate, export, validate
|
||||
from assembly_dataset.teacher import Plan
|
||||
from assembly_dataset.world import solve
|
||||
from assembly_dataset.language import action_text
|
||||
from llm.config import OpenAiConfig
|
||||
|
||||
|
||||
class TestAgent:
|
||||
def __init__(self, invalid=False):
|
||||
self.active = 0
|
||||
self.peak = 0
|
||||
self.invalid = invalid
|
||||
|
||||
async def run(self, prompt, **kwargs):
|
||||
self.active += 1
|
||||
self.peak = max(self.peak, self.active)
|
||||
try:
|
||||
await asyncio.sleep(0.001)
|
||||
plan = {'status': 'ready', 'actions': []} if self.invalid else solve(json.loads(prompt)['scene'])[0]
|
||||
if plan['actions']:
|
||||
scene = json.loads(prompt)['scene']
|
||||
plan['actions'] = [{**a, 'text': action_text(a, scene, Config().prompts)} for a in plan['actions']]
|
||||
return SimpleNamespace(output=Plan.model_validate(plan))
|
||||
finally:
|
||||
self.active -= 1
|
||||
|
||||
|
||||
class PipelineTests(unittest.TestCase):
|
||||
def teacher_config(self, path):
|
||||
return Config(output_dir=Path(path), target_examples=30,
|
||||
teacher=TeacherConfig(enabled=True, fraction=1, concurrency=2, retries=1,
|
||||
llm=OpenAiConfig(provider='openai', model='test')))
|
||||
|
||||
def test_teacher_concurrency_and_validated_provenance(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = self.teacher_config(tmp)
|
||||
agent = TestAgent()
|
||||
asyncio.run(generate(config, agent))
|
||||
report = export(config)
|
||||
self.assertEqual(agent.peak, 2)
|
||||
self.assertEqual(report['sources'], {'openai': 10})
|
||||
self.assertEqual(report['teacher_failures'], 0)
|
||||
self.assertEqual(validate(config)['examples_checked'], 30)
|
||||
|
||||
def test_invalid_teacher_cannot_poison_corpus(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = self.teacher_config(tmp)
|
||||
asyncio.run(generate(config, TestAgent(invalid=True)))
|
||||
report = export(config)
|
||||
self.assertEqual(report['teacher_failures'], 10)
|
||||
self.assertEqual(report['sources'], {'symbolic': 10})
|
||||
self.assertEqual(validate(config)['examples_checked'], 30)
|
||||
|
||||
def test_restart_after_interrupted_generation_equals_clean_run(self):
|
||||
import assembly_dataset.pipeline as pipeline
|
||||
original = pipeline.make_episode
|
||||
def interrupted(index, config):
|
||||
if index >= 40:
|
||||
raise KeyboardInterrupt()
|
||||
return original(index, config)
|
||||
with tempfile.TemporaryDirectory() as first, tempfile.TemporaryDirectory() as second:
|
||||
config = Config(output_dir=Path(first), target_examples=150)
|
||||
with patch.object(pipeline, 'make_episode', side_effect=interrupted):
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
asyncio.run(generate(config))
|
||||
asyncio.run(generate(config))
|
||||
resumed = export(config)
|
||||
clean = Config(output_dir=Path(second), target_examples=150)
|
||||
asyncio.run(generate(clean))
|
||||
uninterrupted = export(clean)
|
||||
self.assertEqual(resumed['files'], uninterrupted['files'])
|
||||
self.assertEqual(validate(config)['examples_checked'], 150)
|
||||
|
||||
def test_corrupted_shard_is_detected(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = Config(output_dir=Path(tmp), target_examples=4)
|
||||
asyncio.run(generate(config))
|
||||
export(config)
|
||||
next(Path(tmp).glob('sft/*/*/*.jsonl')).write_text('broken\n')
|
||||
with self.assertRaisesRegex(ValueError, 'Повреждён'):
|
||||
validate(config)
|
||||
|
||||
def test_all_steps_and_one_mode_obey_total(self):
|
||||
for modes in (['step'], ['branch'], ['full', 'step', 'branch']):
|
||||
with self.subTest(modes=modes), tempfile.TemporaryDirectory() as tmp:
|
||||
config = Config(output_dir=Path(tmp), target_examples=37, modes=modes, step_sampling='all')
|
||||
asyncio.run(generate(config))
|
||||
report = export(config)
|
||||
self.assertEqual(report['examples'], 37)
|
||||
self.assertEqual(validate(config)['examples_checked'], 37)
|
||||
|
||||
def test_smaller_export_does_not_remove_canonical_episodes(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = Config(output_dir=Path(tmp), target_examples=100)
|
||||
asyncio.run(generate(config))
|
||||
original = export(config)
|
||||
config.target_examples = 1
|
||||
self.assertEqual(export(config)['examples'], 1)
|
||||
config.target_examples = 100
|
||||
self.assertEqual(export(config)['files'], original['files'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,47 @@
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from assembly_dataset.config import Config
|
||||
from assembly_dataset.pipeline import generate, export, validate
|
||||
|
||||
|
||||
class CsvExportTests(unittest.TestCase):
|
||||
def test_each_shard_has_readable_csv_with_same_records(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = Config(output_dir=Path(tmp), target_examples=25, shard_size=2)
|
||||
config.prompts.teacher_system = 'Правила'
|
||||
config.prompts.training_system['full'] = 'Текст; с "кавычками"\nи новой строкой'
|
||||
asyncio.run(generate(config))
|
||||
export(config)
|
||||
for path in Path(tmp).glob('sft/*/*/*.jsonl'):
|
||||
counterpart = path.with_suffix('.csv')
|
||||
self.assertTrue(counterpart.exists())
|
||||
self.assertTrue(counterpart.read_bytes().startswith(b'\xef\xbb\xbf'))
|
||||
originals = [json.loads(line) for line in path.read_text().splitlines()]
|
||||
with counterpart.open(encoding='utf-8-sig', newline='') as file:
|
||||
rows = list(csv.DictReader(file, delimiter=';'))
|
||||
self.assertEqual(len(rows), len(originals))
|
||||
for original, row in zip(originals, rows):
|
||||
self.assertEqual(row['ID записи'], original['record_id'])
|
||||
self.assertEqual(row['Системная инструкция'], original['messages'][0]['content'])
|
||||
self.assertEqual(row['План'], original['messages'][2]['content'])
|
||||
self.assertNotRegex(row['План'], r'part_\d|slot_\d')
|
||||
if original['output_format'] == 'text':
|
||||
self.assertNotIn('"skill"', row['План'])
|
||||
self.assertTrue(row['План'])
|
||||
self.assertEqual(validate(config)['examples_checked'], 25)
|
||||
|
||||
def test_corrupt_csv_is_detected(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = Config(output_dir=Path(tmp), target_examples=1)
|
||||
asyncio.run(generate(config))
|
||||
export(config)
|
||||
files = list(Path(tmp).glob('sft/*/*/*.csv'))
|
||||
self.assertEqual(len(files), 2)
|
||||
files[0].write_text('broken')
|
||||
with self.assertRaisesRegex(ValueError, 'Повреждён'):
|
||||
validate(config)
|
||||
@@ -0,0 +1,60 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from assembly_dataset.config import Config
|
||||
from assembly_dataset.pipeline import generate, export, validate
|
||||
from assembly_dataset.world import make_episode, training_rows
|
||||
|
||||
|
||||
class PairedExportTests(unittest.TestCase):
|
||||
def test_paired_shards_and_exact_count(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = Config(output_dir=Path(tmp), target_examples=10, shard_size=2)
|
||||
asyncio.run(generate(config))
|
||||
report = export(config)
|
||||
self.assertEqual(report['examples'], 10)
|
||||
pairs = 0
|
||||
for path in Path(tmp).glob('sft/*/*/[0-9]*.jsonl'):
|
||||
if '.structured.' in path.name:
|
||||
continue
|
||||
structured_path = path.with_suffix('.structured.jsonl')
|
||||
self.assertTrue(structured_path.exists())
|
||||
text_rows = list(map(json.loads, path.read_text().splitlines()))
|
||||
json_rows = list(map(json.loads, structured_path.read_text().splitlines()))
|
||||
self.assertEqual(len(text_rows), len(json_rows))
|
||||
for text, structured in zip(text_rows, json_rows):
|
||||
self.assertEqual(text['record_id'], structured['record_id'])
|
||||
self.assertEqual(text['messages'][1], structured['messages'][1])
|
||||
self.assertNotEqual(text['messages'][0], structured['messages'][0])
|
||||
answer = json.loads(structured['messages'][2]['content'])
|
||||
self.assertEqual(set(answer), {'status', 'plan'})
|
||||
self.assertNotRegex(str(answer), r'part_\d|slot_\d')
|
||||
pairs += 1
|
||||
self.assertEqual(pairs, 10)
|
||||
self.assertEqual(validate(config)['examples_checked'], 10)
|
||||
|
||||
def test_structured_skills_and_branches(self):
|
||||
config = Config(domains=['operator_assistance'], min_components=1, max_components=1,
|
||||
blocked_fraction=0, step_sampling='all')
|
||||
episode = make_episode(0, config)
|
||||
rows = list(training_rows(episode, config, output_format='structured'))
|
||||
full = json.loads(rows[0]['messages'][2]['content'])
|
||||
actions = full['plan']
|
||||
self.assertEqual(actions[2], {'skill': 'pick', 'object': 'распределительная коробка №1'})
|
||||
self.assertEqual(actions[3]['target'], 'оператор')
|
||||
self.assertEqual(actions[6]['target'], 'зона хранения №1')
|
||||
self.assertNotIn('target', actions[7])
|
||||
for row in rows[1:-1]:
|
||||
self.assertEqual(len(json.loads(row['messages'][2]['content'])['plan']), 1)
|
||||
branch = json.loads(rows[-1]['messages'][2]['content'])
|
||||
for i, action in enumerate(branch['plan']):
|
||||
self.assertEqual(action['on_failure'], 'replan_required')
|
||||
self.assertEqual(action['on_success'], f'step_{i+2}' if i+1 < len(actions) else 'ready')
|
||||
|
||||
def test_blocked_empty_plan(self):
|
||||
config = Config(domains=['operator_assistance'], min_components=1, max_components=1, blocked_fraction=1)
|
||||
for row in training_rows(make_episode(0, config), config, output_format='structured'):
|
||||
self.assertEqual(json.loads(row['messages'][2]['content']), {'status': 'blocked', 'plan': []})
|
||||
@@ -0,0 +1,45 @@
|
||||
import asyncio
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from assembly_dataset.config import Config
|
||||
from assembly_dataset.pipeline import generate, export
|
||||
|
||||
|
||||
class ProgressTests(unittest.TestCase):
|
||||
def test_generation_export_and_resume_report_exact_total(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = Config(output_dir=Path(tmp), target_examples=7)
|
||||
output, errors = StringIO(), StringIO()
|
||||
with redirect_stdout(output), redirect_stderr(errors):
|
||||
asyncio.run(generate(config, show_progress=True))
|
||||
report = export(config, show_progress=True)
|
||||
asyncio.run(generate(config, show_progress=True))
|
||||
self.assertEqual(report['examples'], 7)
|
||||
self.assertEqual(output.getvalue(), '')
|
||||
text = errors.getvalue()
|
||||
self.assertIn('Генерация', text)
|
||||
self.assertIn('Экспорт', text)
|
||||
self.assertIn('7/7', text.replace(' ', ''))
|
||||
self.assertIn('100%', text)
|
||||
|
||||
def test_progress_disabled_is_silent(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = Config(output_dir=Path(tmp), target_examples=1)
|
||||
errors = StringIO()
|
||||
with redirect_stderr(errors):
|
||||
asyncio.run(generate(config, show_progress=False))
|
||||
export(config, show_progress=False)
|
||||
self.assertEqual(errors.getvalue(), '')
|
||||
|
||||
def test_empty_dataset_progress(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = Config(output_dir=Path(tmp), target_examples=0)
|
||||
errors = StringIO()
|
||||
with redirect_stderr(errors):
|
||||
asyncio.run(generate(config, show_progress=True))
|
||||
export(config, show_progress=True)
|
||||
self.assertIn('0/0', errors.getvalue().replace(' ', ''))
|
||||
@@ -0,0 +1,78 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
import yaml
|
||||
|
||||
from assembly_dataset.config import Config, load_config
|
||||
from assembly_dataset.pipeline import generate
|
||||
from assembly_dataset.world import make_episode, training_rows
|
||||
from assembly_dataset.language import action_text
|
||||
|
||||
|
||||
class PromptTests(unittest.TestCase):
|
||||
def test_custom_request_and_retry_reach_teacher(self):
|
||||
from assembly_dataset.teacher import enrich, Plan
|
||||
from llm.config import OpenAiConfig
|
||||
config = Config(blocked_fraction=0)
|
||||
config.teacher.fraction = 1.0
|
||||
config.teacher.llm = OpenAiConfig(provider='openai', model='test')
|
||||
config.prompts.teacher_user = 'REQUEST ${payload}'
|
||||
config.prompts.teacher_retry = 'REPAIR ${payload}'
|
||||
episode = make_episode(0, config)
|
||||
agent = SimpleNamespace(run=AsyncMock(side_effect=[
|
||||
SimpleNamespace(output=Plan(status='ready', actions=[])),
|
||||
SimpleNamespace(output=Plan.model_validate({**episode['plan'], 'actions': [{**a, 'text': action_text(a, episode['scene'], config.prompts)} for a in episode['plan']['actions']]})),
|
||||
]))
|
||||
result, error = asyncio.run(enrich(episode, config.teacher, agent, config.prompts))
|
||||
self.assertIsNone(error)
|
||||
first, second = [call.args[0] for call in agent.run.call_args_list]
|
||||
self.assertTrue(first.startswith('REQUEST {'))
|
||||
self.assertTrue(second.startswith('REPAIR {'))
|
||||
self.assertIn('validation_error', json.loads(second.removeprefix('REPAIR ')))
|
||||
self.assertEqual(result['source']['provider'], 'openai')
|
||||
|
||||
def test_external_prompts_relative_paths_and_export(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
prompts = yaml.safe_load(Path('prompts.yaml').read_text())
|
||||
prompts['training_system']['full'] = 'Custom training rules'
|
||||
prompts['training_user'] = 'Input: ${payload}'
|
||||
prompts['task_instructions']['mechanical'] = 'Custom assembly task'
|
||||
(root / 'texts.yaml').write_text(yaml.safe_dump(prompts))
|
||||
(root / 'config.yaml').write_text('prompts_file: texts.yaml\noutput_dir: data\ndomains: [mechanical]\n')
|
||||
config = load_config(root / 'config.yaml')
|
||||
episode = make_episode(0, config)
|
||||
record = next(training_rows(episode, config))
|
||||
self.assertEqual(episode['instruction'], 'Custom assembly task')
|
||||
self.assertEqual(record['messages'][0]['content'], 'Custom training rules')
|
||||
self.assertTrue(record['messages'][1]['content'].startswith('Input: {'))
|
||||
self.assertEqual(config.output_dir, root / 'data')
|
||||
|
||||
def test_prompt_changes_prevent_mixed_resume(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config = Config(output_dir=Path(tmp), target_examples=1)
|
||||
asyncio.run(generate(config))
|
||||
config.prompts.teacher_system = 'Different rules'
|
||||
with self.assertRaises(ValueError):
|
||||
asyncio.run(generate(config))
|
||||
|
||||
def test_invalid_templates_are_rejected(self):
|
||||
from assembly_dataset.prompts import Prompts
|
||||
data = Config().prompts.model_dump()
|
||||
for template in ('No scene', '${unknown}', '${payload.bad}'):
|
||||
with self.subTest(template=template), self.assertRaises(ValueError):
|
||||
Prompts.model_validate({**data, 'teacher_user': template})
|
||||
|
||||
def test_teacher_system_is_passed_to_agent(self):
|
||||
from assembly_dataset.teacher import create_agent
|
||||
from llm.config import OpenAiConfig
|
||||
config = Config()
|
||||
config.teacher.llm = OpenAiConfig(provider='openai', model='test')
|
||||
config.prompts.teacher_system = 'Custom teacher instructions'
|
||||
with patch('llm.factory.LLMModelProvider.create_from_config', new_callable=AsyncMock), patch('pydantic_ai.Agent') as agent:
|
||||
asyncio.run(create_agent(config.teacher, config.prompts))
|
||||
self.assertEqual(agent.call_args.kwargs['system_prompt'], 'Custom teacher instructions')
|
||||
@@ -0,0 +1,77 @@
|
||||
import asyncio
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from assembly_dataset.config import Config
|
||||
from assembly_dataset.world import make_episode, training_rows, validate_plan
|
||||
|
||||
|
||||
class RussianPlanTests(unittest.TestCase):
|
||||
def test_training_answers_are_russian_text_for_all_modes(self):
|
||||
config = Config(blocked_fraction=0)
|
||||
episode = make_episode(0, config)
|
||||
for row in training_rows(episode, config):
|
||||
text = row['messages'][-1]['content']
|
||||
self.assertRegex(text, '[А-Яа-я]')
|
||||
self.assertNotRegex(text, r'part_\d|slot_\d|"skill"|on_success')
|
||||
self.assertFalse(text.startswith('{'))
|
||||
if row['mode'] == 'full':
|
||||
self.assertTrue(text.startswith('1. '))
|
||||
self.assertIn('Захватить', text)
|
||||
|
||||
def test_operator_assistance_holds_until_completion_then_returns_to_storage(self):
|
||||
config = Config(domains=['operator_assistance'], min_components=1, max_components=1, blocked_fraction=0)
|
||||
episode = make_episode(0, config)
|
||||
skills = [a['skill'] for a in episode['plan']['actions']]
|
||||
self.assertEqual(skills, ['detect', 'approach', 'pick', 'move_to_operator', 'hold',
|
||||
'wait_assembly', 'move_to_storage', 'release', 'return_home'])
|
||||
validate_plan(episode, episode['plan'])
|
||||
text = next(training_rows(episode, config))['messages'][-1]['content']
|
||||
self.assertIn('распределительная коробка', text)
|
||||
self.assertIn('завершения сборки', text)
|
||||
self.assertIn('хранения', text)
|
||||
self.assertIn('исходное положение', text)
|
||||
|
||||
def test_teacher_wording_is_exported_without_replacing_it(self):
|
||||
from assembly_dataset.teacher import Plan, enrich
|
||||
from assembly_dataset.language import action_text
|
||||
from llm.config import OpenAiConfig
|
||||
config = Config(blocked_fraction=0)
|
||||
config.teacher.fraction = 1
|
||||
config.teacher.llm = OpenAiConfig(provider='openai', model='test')
|
||||
episode = make_episode(0, config)
|
||||
actions = [{**a, 'text': action_text(a, episode['scene'], config.prompts)} for a in episode['plan']['actions']]
|
||||
actions[0]['text'] = 'Сначала ' + actions[0]['text'].lower()
|
||||
output = Plan(status='ready', actions=actions)
|
||||
class Agent:
|
||||
async def run(self, *args, **kwargs):
|
||||
return SimpleNamespace(output=output)
|
||||
enriched, error = asyncio.run(enrich(episode, config.teacher, Agent(), config.prompts))
|
||||
self.assertIsNone(error)
|
||||
text = next(training_rows(enriched, config))['messages'][-1]['content']
|
||||
self.assertIn(actions[0]['text'], text)
|
||||
|
||||
def test_teacher_cannot_supply_only_english_skill_text(self):
|
||||
from assembly_dataset.teacher import Plan
|
||||
with self.assertRaises(ValueError):
|
||||
Plan(status='ready', actions=[{'skill':'pick','object':'part_0','target':'slot_0','text':'pick: part_0 -> slot_0'}])
|
||||
|
||||
def test_russian_text_cannot_change_the_validated_action(self):
|
||||
from assembly_dataset.language import action_text, validate_texts
|
||||
config = Config(domains=['operator_assistance'], min_components=1, max_components=1, blocked_fraction=0)
|
||||
episode = make_episode(0, config)
|
||||
texts = [action_text(a, episode['scene'], config.prompts) for a in episode['plan']['actions']]
|
||||
for bad in ('Бросить объект «распределительная коробка №1» на пол.',
|
||||
texts[0] + ' Затем отпустить объект.'):
|
||||
with self.subTest(text=bad), self.assertRaises(ValueError):
|
||||
validate_texts(episode, [bad, *texts[1:]], config.prompts)
|
||||
|
||||
def test_approaching_another_object_cannot_interrupt_holding(self):
|
||||
config = Config(domains=['operator_assistance'], min_components=2, max_components=2, blocked_fraction=0)
|
||||
episode = make_episode(0, config)
|
||||
actions = episode['plan']['actions']
|
||||
# Execute the second object's approach while the first is being held.
|
||||
reordered = actions[:5] + actions[9:11] + actions[5:9] + actions[11:]
|
||||
with self.assertRaises(ValueError):
|
||||
validate_plan(episode, {'status': 'ready', 'actions': reordered})
|
||||
Reference in New Issue
Block a user