173 lines
10 KiB
Python
173 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""Д6 — МАТЕРИАЛ ПАРЫ ja→ru: гардрейл, гейт прав, отбор единиц. $0.
|
||
|
||
Источник: `~/books/isekai_majutsushi_jp.txt` (файл владельца, вне git — правило Р8). Площадка
|
||
`novel18.syosetu.com`, то есть срез ЗАВЕДОМО взрослый; аннотация книги сама называет 洗脳 и 暴力.
|
||
|
||
⚠⚠ ГАРДРЕЙЛ ПЕРВЫМ, ДО ЛЮБОГО ДРУГОГО ДЕЙСТВИЯ. Гардрейл владельца (вынесен в `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" / "isekai_majutsushi_jp.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]:
|
||
"""Главы по разделителю площадки; заголовок остаётся в теле — он часть материала."""
|
||
raw = text()
|
||
parts = re.split(r"\n=+\n", raw)
|
||
out = []
|
||
for i, p in enumerate(parts):
|
||
p = p.strip()
|
||
if len(p) >= MIN_CHARS:
|
||
out.append(dict(idx=i, text=p))
|
||
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())
|