OPEN SOURCE
software/scripts/verify_output.py
このサイトで読み、必要なファイルを保存できます。
日常訳はAIを用いて作成された翻訳です。人間の聖書学・原語の専門家による全訳の検証は確認されていません。既存の翻訳や原語資料と照らし合わせてお読みください。
1#!/usr/bin/env python32"""Read-only structural and site-reference audit for 日常訳第1版 (stdlib only)."""3from __future__ import annotations45import argparse6import csv7import hashlib8import json9import re10from collections import Counter11from pathlib import Path1213ROOT = Path(__file__).resolve().parents[3]14DEFAULT_SOURCE = ROOT / 'cli/translate-bible/data/output'15DEFAULT_BASELINE = ROOT / 'web/japanbible-net/web/public/JapKougo.csv'16TRANSLATION = '日常訳第1版'171819def book_id(name):20name = re.sub(r'^III ', '3', name)21name = re.sub(r'^II ', '2', name)22name = re.sub(r'^I ', '1', name)23return name.lower().replace(' ', '').replace('_', '').replace('revelationofjohn', 'revelation')242526def unique_object(pairs):27result = {}28for key, value in pairs:29if key in result:30raise ValueError(f'duplicate JSON key: {key}')31result[key] = value32return result333435def read_baseline(path):36result = {}37with path.open(encoding='utf-8-sig', newline='') as stream:38reader = csv.DictReader(stream, strict=True)39if reader.fieldnames != ['Book', 'Chapter', 'Verse', 'Text']:40raise ValueError('baseline must have Book,Chapter,Verse,Text header')41for row in reader:42if None in row or any(value is None for value in row.values()):43raise ValueError('malformed baseline row')44key = (book_id(row['Book']), int(row['Chapter']), int(row['Verse']))45if key in result or min(key[1:]) < 1:46raise ValueError(f'invalid/duplicate baseline reference: {key}')47result[key] = row['Text']48if len({key[0] for key in result}) != 66 or len({key[:2] for key in result}) != 1189:49raise ValueError('baseline must contain all 66 books and 1,189 chapters')50return result515253def audit(source, baseline):54issues, rows, manifest = [], {}, []55expected_books = {key[0] for key in baseline}56expected_chapters = {key[:2] for key in baseline}57seen_chapters = set()5859def issue(code, location, detail):60issues.append({'code': code, 'location': location, 'detail': detail})6162if not source.is_dir():63issue('missing-source', str(source), 'Output directory does not exist')64for directory in sorted(source.iterdir()) if source.is_dir() else []:65if directory.name.startswith('.'):66continue67book = book_id(directory.name)68if not directory.is_dir() or book not in expected_books:69issue('unexpected-path', directory.name, 'Expected a canonical book directory')70continue71for path in sorted(directory.iterdir()):72if path.name.startswith('.'):73continue74location = str(path.relative_to(source))75match = re.fullmatch(r'chapter_([0-9]+)\.json', path.name)76if not path.is_file() or not match:77issue('unexpected-path', location, 'Expected chapter_NN.json')78continue79chapter = int(match[1])80if path.name != f'chapter_{chapter:02d}.json':81issue('noncanonical-filename', location, 'Filename is not chapter_NN.json')82if (book, chapter) in seen_chapters:83issue('duplicate-chapter', location, 'Multiple files map to the same chapter')84seen_chapters.add((book, chapter))85if (book, chapter) not in expected_chapters:86issue('unexpected-chapter', location, 'Chapter absent from site baseline')87try:88raw = path.read_bytes()89manifest.append({'path': location, 'sha256': hashlib.sha256(raw).hexdigest()})90data = json.loads(raw, object_pairs_hook=unique_object)91except (OSError, ValueError, UnicodeError) as error:92issue('invalid-json', location, str(error))93continue94if not isinstance(data, dict):95issue('invalid-schema', location, 'Chapter must be an object')96continue97if data.get('book') != directory.name or type(data.get('chapter')) is not int or data['chapter'] != chapter:98issue('metadata-mismatch', location, 'book/chapter metadata must match path')99verses = data.get('verses')100if not isinstance(verses, dict) or not verses:101issue('invalid-schema', location, 'verses must be a nonempty object')102continue103for number, record in verses.items():104ref = f'{book} {chapter}:{number}'105if not re.fullmatch(r'[1-9][0-9]*', number):106issue('invalid-verse-number', ref, 'Expected positive integer string without leading zeros')107continue108key = (book, chapter, int(number))109if key in rows:110issue('duplicate-verse', ref, 'Multiple records map to this reference')111continue112if not isinstance(record, dict) or not isinstance(record.get('text'), str) or not record['text'].strip():113issue('invalid-text', ref, 'Expected nonempty text string')114continue115text = record['text']116rows[key] = text117if re.search(r'[\r\n\x00-\x08\x0b-\x1f\x7f\u2028\u2029]', text):118issue('control-character', ref, 'Control/newline character incompatible with line-based site CSV parser')119if re.search(r'<[^>]*>|\ufffd|```', text):120issue('invalid-plain-text', ref, 'Markup, replacement character or code fence in plain text')121if text != text.strip():122issue('surrounding-whitespace', ref, 'Site parser would trim this text')123ruby = record.get('text_with_furigana')124if ruby is not None:125if not isinstance(ruby, str):126issue('invalid-furigana', ref, 'text_with_furigana must be a string')127elif ruby.strip():128plain = re.sub(r'<rt\b[^>]*>.*?</rt>|<rp\b[^>]*>.*?</rp>', '', ruby)129plain = re.sub(r'</?(?:ruby|rb)\b[^>]*>', '', plain)130if plain != text:131issue('furigana-mismatch', ref, 'Ruby base text differs from plain text or contains unsupported markup')132for book, chapter in sorted(expected_chapters - seen_chapters):133issue('missing-chapter', f'{book} {chapter}', 'No chapter file')134for key in sorted(baseline.keys() - rows.keys()):135issue('missing-verse', f'{key[0]} {key[1]}:{key[2]}', 'No valid text at site reference; review versification before publishing')136for key in sorted(rows.keys() - baseline.keys()):137issue('extra-verse', f'{key[0]} {key[1]}:{key[2]}', 'Reference absent from site corpus; review versification before publishing')138summary = {139'books': len({key[0] for key in rows}), 'chapter_files': len(manifest),140'chapters': len({key[:2] for key in rows}), 'valid_text_records': len(rows),141'baseline_verses': len(baseline), 'baseline_empty_texts': sum(not text.strip() for text in baseline.values()), 'matching_references': len(rows.keys() & baseline.keys()),142'issues': dict(sorted(Counter(item['code'] for item in issues).items())),143}144report = {'translation': TRANSLATION, 'source': str(source.resolve()), 'summary': summary,145'passed': not issues, 'issues': issues, 'source_manifest': manifest}146return report, rows147148149def main():150parser = argparse.ArgumentParser(description=__doc__)151parser.add_argument('--source', type=Path, default=DEFAULT_SOURCE)152parser.add_argument('--baseline', type=Path, default=DEFAULT_BASELINE)153parser.add_argument('--report', type=Path, required=True, help='JSON report destination (outside source)')154args = parser.parse_args()155if args.report.resolve().is_relative_to(args.source.resolve()) or args.report.resolve() == args.baseline.resolve():156parser.error('report must not overwrite source data or baseline')157try:158baseline = read_baseline(args.baseline)159report, _ = audit(args.source, baseline)160report['baseline'] = str(args.baseline.resolve())161report['baseline_sha256'] = hashlib.sha256(args.baseline.read_bytes()).hexdigest()162args.report.parent.mkdir(parents=True, exist_ok=True)163args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')164except (OSError, ValueError, csv.Error) as error:165parser.exit(2, f'Cannot audit: {error}\n')166print(TRANSLATION)167print(json.dumps(report['summary'], ensure_ascii=False, indent=2))168print(f'Report: {args.report}')169return 0 if report['passed'] else 1170171172if __name__ == '__main__':173raise SystemExit(main())174