#!/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 select(cs: list[dict]) -> tuple[list[dict], set[int], list]: """Гейт прав и отбор единиц. Вынесено из `main()`, чтобы отбор был ВЫЗЫВАЕМ, а не только печатаем: покупка и судейство обязаны брать ровно те единицы, что напечатал отчёт.""" 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]} pool = [c for i, c in enumerate(cs) if i not in dropped] 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) return ordered[lo:lo + N_UNITS], dropped, dens def units() -> list[dict]: """Единицы среза Д6 для покупки и судейства — ТОЛЬКО из приколотого манифеста. ⚠ Сама не решает и не «выбирает по-новой». Гардрейл 18+ разрешается словом владельца, и решение это разовое: пока манифеста нет, отбор не состоялся, и молча подставить набор нельзя — иначе библиотечный вызов обойдёт гардрейл, который CLI честно охраняет. Расхождение приколотого uid с пересчитанным = источник сдвинулся под уже купленными клетками; это СТОП, а не предупреждение (класс, пойманный докачкой глав на zh-срезе). """ if not MANIFEST.exists(): raise SystemExit(f"⛔ манифест {MANIFEST} не записан: отбор ja не заморожен. " "Прогнать material_ja.py --units при слове владельца по гардрейлу.") man = json.loads(MANIFEST.read_text(encoding="utf-8")) want = man["ja"]["uids"] # ⚠ ГАРДРЕЙЛ ПРИМЕНЯЕТСЯ И ЗДЕСЬ. `main()` при санкции владельца выбрасывает помеченные # чанки ДО отбора, и манифест записан из отфильтрованного списка. Если бы `units()` считала # по нефильтрованному, дециль и окно отбора сместились бы — приколотые uid не нашлись бы, и # прибор дал бы вечный ложный СТОП. Сегодня это тождество (0 срабатываний из 48), но # тождество СЛУЧАЙНОЕ, а не гарантированное. cs = chunks() bad = set(guardrail(cs)) if bad and not man.get("sanctioned_exclude"): raise SystemExit(f"⛔ гардрейл 18+ помечает {len(bad)} чанков, а санкция владельца в " f"манифесте не записана — материал не идёт в отбор, СТОП") have = {u["uid"]: u for u in select([c for i, c in enumerate(cs) if i not in bad])[0]} miss = [u for u in want if u not in have] if miss: raise SystemExit(f"⛔ ЕДИНИЦЫ РАЗЪЕХАЛИСЬ С МАНИФЕСТОМ ja: нет {miss[:3]} — " "источник изменился под купленными клетками, СТОП") return [have[u] for u in want] 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=== ГЕЙТ ПРАВ (верхний дециль эротической плотности снимается ДО отбора) ===") units_, dropped, dens = select(cs) cut = max(1, len(cs) // 10) print(f"снято чанков {len(dropped)} из {len(cs)} (дециль); плотность верхнего " f"{dens[0][0]:.2f}/1000 знаков, порога дециля {dens[cut - 1][0]:.2f}, медиана " f"{dens[len(dens) // 2][0]:.2f}") print("\n=== ОТБОР ЕДИНИЦ (детерминированный, методика эксп-21 §0) ===") ls = [len(u["source"]) for u in units_] print(f"выбрано {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), sanctioned_exclude="--owner-sanctioned-exclude" in sys.argv, 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())