textmachine/eval/dovodka/material_ja.py

192 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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-9-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())