#!/usr/bin/env python3 """Д6 — МАТЕРИАЛ ПАРЫ ja→ru: гардрейл, гейт прав, отбор единиц. $0. Источник: `~/books/enkan_no_hate_ja.txt` — 「円環の果てに ―裏切られた魂、二度目の生―」, `ncode.syosetu.com/n7233mn`, первая публикация **2026-07-30**, 18 глав скачано из 56. Книга подобрана тем же механическим критерием, что китайский срез эксп-22 (§0.2): площадка отдаёт текст главы БЕЗ JS; дата первой публикации читается у самой площадки; жанр из продуктового семейства (異世界転生, преданный герой, вторая жизнь — тот же троп, что у 阴阳天帝诀). ⚠ **ДВА ФИЛЬТРА, КОТОРЫХ НЕ БЫЛО У ZH-СРЕЗА, и оба обязательны для ja.** (1) **Метка `AI直接使用`.** Syosetu помечает работы, написанные ИИ, и среди свежих ранобэ таких много: из первых трёх кандидатов по длине главы ВСЕ ТРИ оказались AI-сгенерированными. Мерить на них перевод нельзя — это не человеческая проза, и «невиданность» такого текста ничего не значит. Кандидаты с ключами `AI直接使用`/`AI支援`/`生成AI` отбрасываются механически. (2) **Прежний источник `isekai_majutsushi_jp` (novel18) СНЯТ:** гардрейл 18+ уровня 3 дал 3 срабатывания из 184 чанков, помеченное не читалось, обработка прекращена. Замена подобрана по слову владельца 10.08. ⚠⚠ ГАРДРЕЙЛ ПЕРВЫМ, ДО ЛЮБОГО ДРУГОГО ДЕЙСТВИЯ. Гардрейл владельца (вынесен в `eval/README.md`): **18+ уровень 3 — несовершеннолетние в сексуальном контексте — не обрабатывать и не анализировать ни в каком виде.** Здесь он реализован механически: чанк, где ПЛОТНО совпадают маркеры возраста и явные сексуальные маркеры, помечается и материал целиком уходит в СТОП с пингом владельцу. Ни один такой чанк не печатается и дальше по конвейеру не идёт — печатается только его номер и счёт маркеров. Детектор существует, чтобы ИСКЛЮЧАТЬ, и его срабатывание — не повод читать внимательнее. Дальше — та же лестница, что у эксп-22 §0.2, и по тем же причинам: 1. гейт ПРАВ (маршрутизация по договорам провайдеров, не по поведению модели): у `deepseek` и `zai` класс `sexually-explicit` — FORBIDDEN, поэтому верхний дециль по плотности эротических маркеров снимается ДО отбора единиц; 2. отбор единиц детерминированный: дедуп по подписи, ключ артефакта — хеш источника, сортировка по (длина, uid), из середины распределения длин; 3. манифест: единицы прикалываются к файлу, иначе докачка материала молча сдвинет уже купленный замер (ровно это случилось с эксп-22 и чинилось в паке 23). Запуск: eval/.venv/bin/python eval/dovodka/material_ja.py [--units] """ from __future__ import annotations import hashlib import json import re import sys from pathlib import Path SRC = Path.home() / "books" / "enkan_no_hate_ja.txt" OUT = Path.home() / "books" / "dovodka" MANIFEST = OUT / "manifest-ja.json" TARGET_CHARS, MIN_CHARS, N_UNITS = 1100, 700, 9 # ja плотнее zh на знак: цель короче # ── маркеры гардрейла ───────────────────────────────────────────────────────────────────────── AGE = re.compile(r"(小学生|中学生|高校生|幼女|幼児|少女|少年|児童|子供|子ども|ロリ|" r"[0-90-9一二三四五六七八九十]{1,2}\s*[歳才])") SEX = re.compile(r"(性行為|セックス|挿入|射精|愛撫|陰茎|陰部|膣|乳房|裸体|全裸|絶頂|喘|" r"犯[すされし]|強姦|レイプ|淫|媚薬|発情)") # Эротическая плотность для ГЕЙТА ПРАВ — шире, чем гардрейл: это про договоры провайдеров. EROTIC = re.compile(r"(キス|口づけ|抱[きく]しめ|裸|胸|尻|太もも|下着|色気|艶|情事|房事|" r"性的|欲情|快感|喘|淫|媚薬|発情|愛撫|挿入|射精|セックス|性行為)") def text() -> str: if not SRC.exists(): raise SystemExit(f"⛔ материала нет: {SRC}") return SRC.read_text(encoding="utf-8", errors="replace") def chapters() -> list[dict]: """Главы по разделителю площадки; заголовок остаётся в теле — он часть материала.""" # ⚠ Разделитель — заголовок вида `=== 7 · 身に覚えのない署名 ===`, который ставит качалка. # Первая редакция резала по `\n=+\n` и НЕ находила его: весь файл становился ОДНОЙ главой, # отбор давал девять соседних кусков одной сцены, и прирост мощности был бы мнимым. Ровно # эту ошибку эксп-23 §11 ловил у себя («главы эксп-22 исключены ЦЕЛИКОМ, иначе новые единицы # делили бы с ними сцену и лексику»). Поймано печатью «глав 1». raw = text() out = [] for m in re.finditer(r"^=== (\d+) · (.*?) ===$\n(.*?)(?=^=== \d+ · |\Z)", raw, re.M | re.S): body = m.group(3).strip() if len(body) >= MIN_CHARS: out.append(dict(idx=int(m.group(1)), text=body)) if not out: raise SystemExit("⛔ ни одной главы не распознано — разделитель не тот, отбор НЕ делается") return out def chunks() -> list[dict]: """Абзацные чанки; абзац не рвётся, хвост главы прирастает к предыдущему (правило эксп-22).""" out = [] for ch in chapters(): made, buf, size = [], [], 0 for para in ch["text"].split("\n"): para = para.strip() if not para: continue buf.append(para) size += len(para) if size >= TARGET_CHARS: made.append(dict(text="\n".join(buf), chapter=ch["idx"])) buf, size = [], 0 if size: tail = "\n".join(buf) if size >= MIN_CHARS or not made: made.append(dict(text=tail, chapter=ch["idx"])) else: made[-1]["text"] += "\n" + tail out += made return out def uid_of(s: str) -> str: return hashlib.sha1(s.encode("utf-8")).hexdigest()[:10] # noqa: S324 def guardrail(cs: list[dict]) -> list[int]: """Индексы чанков КЛАССА, КОТОРЫЙ НЕ ОБРАБАТЫВАЕТСЯ. Содержимое не печатается никогда.""" hits = [] for i, c in enumerate(cs): t = c["text"] a, s = len(AGE.findall(t)), len(SEX.findall(t)) if a and s and (a + s) >= 4: hits.append(i) return hits def main() -> int: cs = chunks() print(f"Д6 МАТЕРИАЛ ja→ru · источник {SRC.name} · знаков {len(text())} · чанков {len(cs)}") print("\n=== ГАРДРЕЙЛ 18+ УРОВНЯ 3 (проверяется ПЕРВЫМ) ===") bad = guardrail(cs) if bad: print(f"⛔ СТОП. Чанков с плотным совпадением возрастных и явных сексуальных маркеров: " f"{len(bad)} из {len(cs)} (номера: {bad[:12]}{'…' if len(bad) > 12 else ''}).") print(" Содержимое НЕ печатается и дальше не идёт. Материал непригоден по гардрейлу\n" " владельца; замена без его слова НЕ ищется (промт: «непригоден по пробе → СТОП\n" " и пинг»).") if "--owner-sanctioned-exclude" not in sys.argv: print("\n Детектор КОНСЕРВАТИВЕН намеренно: 少年/少女/子供 — частотные слова японской\n" " прозы, и совпасть с эротическим маркером они могут безобидно. Развести это\n" " можно только ЧТЕНИЕМ помеченных мест, а читать их правило запрещает — значит\n" " решает не сессия. Обход существует и требует слова владельца:\n" " --owner-sanctioned-exclude помеченные чанки выбрасываются механически,\n" " остальные 181 идут в отбор; в отчёт пишется,\n" " сколько и почему выброшено.\n" " Прекращаю обработку источника.") return 1 print(f"\n ⚠ ОБХОД ПО СЛОВУ ВЛАДЕЛЬЦА: {len(bad)} помеченных чанков выброшены " "механически, без чтения.") cs = [c for i, c in enumerate(cs) if i not in set(bad)] print(f" осталось чанков: {len(cs)}") else: print(f"[OK ] срабатываний нет: 0 из {len(cs)} чанков") print("\n=== ГЕЙТ ПРАВ (верхний дециль эротической плотности снимается ДО отбора) ===") dens = sorted(((len(EROTIC.findall(c["text"])) / max(1, len(c["text"])) * 1000, i) for i, c in enumerate(cs)), reverse=True) cut = max(1, len(cs) // 10) dropped = {i for _, i in dens[:cut]} print(f"снято чанков {len(dropped)} из {len(cs)} (дециль); плотность верхнего " f"{dens[0][0]:.2f}/1000 знаков, порога дециля {dens[cut - 1][0]:.2f}, медиана " f"{dens[len(dens) // 2][0]:.2f}") pool = [c for i, c in enumerate(cs) if i not in dropped] print("\n=== ОТБОР ЕДИНИЦ (детерминированный, методика эксп-21 §0) ===") uniq: dict[str, dict] = {} for c in pool: u = uid_of(c["text"]) uniq.setdefault(u, dict(uid=u, source=c["text"], chapter=c["chapter"])) ordered = sorted(uniq.values(), key=lambda x: (len(x["source"]), x["uid"])) mid = len(ordered) // 2 lo = max(0, mid - N_UNITS // 2) units = ordered[lo:lo + N_UNITS] ls = [len(u["source"]) for u in units] print(f"пул {len(ordered)} уникальных · выбрано {len(units)} из середины распределения длин") print(f"знаков {min(ls)}…{max(ls)}, медиана {sorted(ls)[len(ls) // 2]} · " f"глав {len({u['chapter'] for u in units})}") for u in units: print(f" {u['uid']} гл.{u['chapter']:<4d} знаков {len(u['source']):5d}") if "--units" in sys.argv: OUT.mkdir(parents=True, exist_ok=True) MANIFEST.write_text(json.dumps( dict(source=SRC.name, n_chunks=len(cs), dropped_rights=len(dropped), ja=dict(uids=[u["uid"] for u in units])), ensure_ascii=False, indent=1), encoding="utf-8") print(f"\nманифест → {MANIFEST} (единицы приколоты; докачка их больше не сдвинет)") else: print("\n(манифест НЕ записан — прогнать с флагом --units, когда состав утверждён)") return 0 if __name__ == "__main__": sys.exit(main())