"""Apply reviewed reference routes to a release candidate, retaining every conflict. This never edits the translation harness's source-numbered output. An audit is bound to a complete input snapshot; no lexical similarity score authorizes edits. """ from __future__ import annotations import hashlib import json import re from collections import Counter, defaultdict from pathlib import Path from src.output.originals import load_site def digest(value): return hashlib.sha256(value.encode()).hexdigest() def read_output(directory): records, files = {}, {} for path in sorted(Path(directory).glob('*/chapter_*.json')): content = path.read_bytes() files[str(path.relative_to(directory))] = hashlib.sha256(content).hexdigest() data = json.loads(content) for verse, record in data['verses'].items(): key = f'{path.parent.name}:{data["chapter"]}:{int(verse)}' if key in records or not record['text'].strip(): raise ValueError(f'Duplicate or empty translation: {key}') records[key] = record['text'] if not records: raise ValueError('No translations found') return records, files def ref_order(ref): book, ch, verse = ref.split(':') return book, int(ch), int(verse) def candidate_hashes(choices): return sorted(({'source': item['source'], 'text_sha256': digest(item['text'])} for item in choices), key=lambda item: ref_order(item['source'])) def validate_repair(target, repair, choices): if repair['expected_candidates'] != candidate_hashes(choices): raise ValueError(f'Repair candidates changed: {target}') text = repair['text'] if not isinstance(text, str) or not text.strip() or re.search(r'[\x00-\x1f\x7f<>\ufffd]|```', text): raise ValueError(f'Invalid repaired text: {target}') if not repair.get('reason') or not repair.get('evidence_sha256') or not repair.get('verified_by'): raise ValueError(f'Repair lacks review evidence: {target}') return text def route_record(source, text, review): """Return lossless character slices, or hold a record requiring more review.""" if review is None: return [(source, text)] if review['text_sha256'] != digest(text): raise ValueError(f'Reviewed text changed: {source}') routes = review.get('routes') if routes is None: decision = review['decision'] if decision == 'same': return [(source, text)] if decision == 'mapped' and len(review['mapped_targets']) == 1: return [(review['mapped_targets'][0], text)] if decision not in ('mapped', 'mixed', 'unclear'): raise ValueError(f'Unknown review decision: {source}') return [] cursor, result = 0, [] for route in routes: start, end = route['start'], route['end'] if start != cursor or not start < end <= len(text): raise ValueError(f'Character loss or duplication: {source}') result.append((route['target'], text[start:end])) cursor = end if cursor != len(text): raise ValueError(f'Unassigned characters: {source}') return result def align(records, site_refs, review): candidates, held = defaultdict(list), {} reviewed = review['records'] if reviewed.keys() - records.keys(): raise ValueError('Review contains unknown source references') for source, text in records.items(): routes = route_record(source, text, reviewed.get(source)) if not routes: held[source] = {'text': text, 'review': reviewed[source]} for target, part in routes: book, ch, verse = ref_order(target) if target not in site_refs and not (verse == 0 and f'{book}:{ch}:1' in site_refs): raise ValueError(f'Route outside site: {source} -> {target}') candidates[target].append({'source': source, 'text': part}) resolved, conflicts, dispositions = {}, {}, {} repairs = review.get('target_repairs', {}) for target in repairs: if target not in site_refs: raise ValueError(f'Repair outside site: {target}') candidates.setdefault(target, []) resolutions = review.get('resolutions', {}) if resolutions.keys() - candidates.keys(): raise ValueError('Resolution has no candidates') for target, choices in sorted(candidates.items(), key=lambda item: ref_order(item[0])): choices.sort(key=lambda item: ref_order(item['source'])) if target in repairs: repair = repairs[target] text = validate_repair(target, repair, choices) dispositions[target] = {'reason': repair['reason'], 'all_candidates': choices, 'repair': repair} resolved[target] = {'text': text, 'sources': [item['source'] for item in choices], 'restored': not choices, 'reviewed_repair': True} continue resolution = resolutions.get(target) if resolution: actual = [item['source'] for item in choices] if sorted(actual) != sorted(resolution['expected_candidates']): raise ValueError(f'Resolution candidates changed: {target}') by_source = {item['source']: item for item in choices} selected = resolution['sources'] if len(set(actual)) != len(actual) or not selected or len(set(selected)) != len(selected) or set(selected) - by_source.keys(): raise ValueError(f'Invalid selection: {target}') parts = [by_source[source] for source in selected] dispositions[target] = {'reason': resolution['reason'], 'selected': selected, 'all_candidates': choices} elif target.endswith(':0'): parts = choices # Consecutive, separately numbered superscription lines. elif len({item['text'] for item in choices}) == 1: parts = choices[:1] if len(choices) > 1: dispositions[target] = {'reason': 'Identical text at the same destination', 'selected': [parts[0]['source']], 'all_candidates': choices} else: conflicts[target] = choices continue resolved[target] = {'text': ''.join(item['text'] for item in parts), 'sources': [item['source'] for item in parts]} missing = sorted(site_refs - candidates.keys(), key=ref_order) extras = {} for source in sorted(records.keys() - site_refs, key=ref_order): targets = [t for t, choices in candidates.items() if any(item['source'] == source for item in choices)] extras[source] = {'text': records[source], 'targets': targets, 'basis': reviewed[source]['basis'], 'decision': reviewed[source]['decision'], 'status': 'held' if source in held else ('conflict' if any(t in conflicts for t in targets) else 'resolved'), 'destinations': {t: {'candidates': candidates[t], 'selected': resolved.get(t)} for t in targets}} changes = {key: value for key, value in resolved.items() if value['sources'] != [key] or value['text'] != records.get(key)} return {'schema': 1, 'translation_name': '日常訳第1版', 'numbering_ready': not (missing or conflicts or held), 'summary': {'source_records': len(records), 'site_references': len(site_refs), 'reviewed_sensitive_records': len(reviewed), 'reviewers': dict(Counter(r['reviewer'] for r in reviewed.values())), 'resolved_site_references': len(site_refs & resolved.keys()), 'aligned_changes': len(changes), 'missing_passages': len(missing), 'conflicting_destinations': len(conflicts), 'held_records': len(held), 'extra_references': len(extras)}, 'aligned_changes': changes, 'missing_passages': missing, 'conflicts': conflicts, 'held_records': held, 'resolutions': dispositions, 'extra_references': extras} def build_audit(source, review_path, out): source, review_path, out = Path(source), Path(review_path), Path(out).resolve() if out.is_relative_to(source.resolve()) or out == review_path.resolve(): raise ValueError('Audit must not overwrite its inputs') records, files = read_output(source) review = json.loads(review_path.read_text()) if files != review['input_files']: raise ValueError('Translation snapshot changed; refresh the affected reviews') site, _ = load_site() site_refs = {':'.join(map(str, key)) for key in site} # Bind the reference text too, since numbering alone cannot prove alignment. site_hash = digest(json.dumps({':'.join(map(str, k)): v for k, v in site.items()}, ensure_ascii=False, sort_keys=True)) if review['site_sha256'] != site_hash: raise ValueError('Site reference text changed; refresh the review') for relative, expected in review.get('repair_source_files', {}).items(): path = (source.parent / relative).resolve() if not path.is_relative_to((source.parent / 'sources').resolve()): raise ValueError('Repair source path outside sources directory') if hashlib.sha256(path.read_bytes()).hexdigest() != expected: raise ValueError(f'Repair source text changed: {relative}') result = align(records, site_refs, review) result['missing_reference_text'] = {ref: site[ref_order(ref)] for ref in result['missing_passages']} result['input_files'] = files result['site_sha256'] = site_hash result['review_sha256'] = hashlib.sha256(review_path.read_bytes()).hexdigest() result['usage'] = review['usage'] out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n') return result def export_aligned(source, audit_path, out): """Write a complete, separately numbered release; never mutate raw records.""" source, audit_path, out = Path(source), Path(audit_path), Path(out).resolve() if out.is_relative_to(source.resolve()) or source.resolve().is_relative_to(out): raise ValueError('Release must be separate from raw translation output') if out.exists() and any(out.iterdir()): raise ValueError('Release destination must be empty') records, files = read_output(source) audit = json.loads(audit_path.read_text()) if not audit['numbering_ready'] or files != audit['input_files'] or any( audit.get(key) for key in ('missing_passages', 'conflicts', 'held_records') ): raise ValueError('Numbering audit incomplete or source snapshot changed') site, codes = load_site() site_hash = digest(json.dumps({':'.join(map(str, k)): v for k, v in site.items()}, ensure_ascii=False, sort_keys=True)) if audit['site_sha256'] != site_hash: raise ValueError('Release reference text changed') books = {book: {'schema': 1, 'book': code, 'translation': '日常訳第1版', 'chapters': {}} for book, code in codes.items()} changes = audit['aligned_changes'] for key in sorted(site): book, ch, verse = key ref = ':'.join(map(str, key)) text = changes[ref]['text'] if ref in changes else records.get(ref) if not text: raise ValueError(f'Missing release verse: {ref}') chapter = books[book]['chapters'].setdefault(str(ch), {'heading': '', 'verses': {}}) chapter['verses'][str(verse)] = text for ref, change in changes.items(): book, ch, verse = ref_order(ref) if verse == 0: books[book]['chapters'][str(ch)]['heading'] = change['text'] out.mkdir(parents=True, exist_ok=True) hashes = {} for book, payload in books.items(): filename = codes[book] + '.json' text = json.dumps(payload, ensure_ascii=False, separators=(',', ':')) + '\n' (out / filename).write_text(text) hashes[filename] = digest(text) manifest = {'schema': 1, 'translation': '日常訳第1版', 'verses': len(site), 'chapters': len({k[:2] for k in site}), 'books': len(books), 'files': hashes, 'audit_sha256': hashlib.sha256(audit_path.read_bytes()).hexdigest()} (out / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n') return manifest