232 lines
16 KiB
Python
232 lines
16 KiB
Python
#!/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())
|