248 lines
13 KiB
Python
248 lines
13 KiB
Python
"""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}
|