textmachine/eval/tenant_panel/material.py

294 lines
16 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
"""Ф0.2 — МАТЕРИАЛ ЭКСП-22: невиданный zh-срез + ось переноса en. $0 (кроме проб контаминации).
Почему новая книга. Эксп-21 мерил главный zh-контраст на 蛊真人, которую собственная проба
оценила как узнанную моделью 4/5 — наравне с положительным контролем. Внутреннюю валидность
сравнения армов это не рушит (все армы видят один материал), но перенос ВЕЛИЧИН — рушит, и
строка 55 бэклога осталась открытой. Промт эксп-22 заказывает срез повторно и жёстко.
Метод поиска (объявляется здесь, потому что «нашёл в вебе» без метода не проверяемо):
1. Площадка выбрана по критерию «текст главы отдаётся сервером» — проверено curl'ом:
七猫/番茄 отдают Vue-каркас без текста, 纵横中文网 (zongheng.com, Baidu) отдаёт
`<div class="content">` с полной главой. Пиратские агрегаторы не рассматривались.
2. Из блока новинок главной страницы взяты 17 книг, у каждой снято поле `首发时间`
(дата ПЕРВОЙ публикации, печатается самой площадкой на странице главы).
3. Отобрана книга с `首发时间` в июле 2026 — то есть заведомо ПОСЛЕ любого правдоподобного
среза обучения — и с жанром, совпадающим с продуктовым (东方玄幻 = то же семейство, что
蛊真人), и с объёмом, которого хватает на 16 единиц с запасом на дедуп.
4. Непереведённость проверяется поиском по ru-площадкам (`--check-ru`), контаминация —
пробой узнавания/клоуза (`contam.py`), порог эксп-21: узнавание ≥3/5 = материал непригоден.
⚠ Текст книги в репозиторий и в отчёт НЕ попадает: здесь только метрики и хеши.
"""
from __future__ import annotations
import hashlib
import json
import re
import subprocess
import sys
import time
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
OUT = Path.home() / "books" / "tenant-panel"
SRC = OUT / "zh-source"
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
# Книга среза. `first_chapter` снят со страницы книги, дальше идём по ссылке «следующая глава» —
# нумерация id не гарантирована последовательной, поэтому обход по ссылкам, а не по арифметике.
BOOK = dict(
site="zongheng.com", book_id=1569059, first_chapter=94741845,
title="阴阳天帝诀", author="", genre="玄幻奇幻 / 东方玄幻",
first_pub="2026-07-13", # поле 首发时间 со страницы главы
url="https://www.zongheng.com/detail/1569059",
)
# Резерв на случай провала пробы контаминации у основной книги (объявляется ДО замера, чтобы
# выбор не оказался подгонкой под результат).
BACKUP = dict(
site="zongheng.com", book_id=1573203, first_chapter=0,
title="吞仙葫", genre="玄幻奇幻 / 东方玄幻", first_pub="2026-07-23",
url="https://www.zongheng.com/detail/1573203",
)
TARGET_CHARS = 1700 # медиана источника единицы эксп-21 — 1679 знаков
MIN_CHARS, MAX_CHARS = 1100, 2600
N_ZH, N_EN = 16, 6
# ⚠ ГЕЙТ ToS, объявлен ДО отбора единиц. Срез — взрослая вебновелла: слой насилия плотный (это
# рабочий случай продукта и его меряют все прежние паки), но у ДВУХ якорных провайдеров панели
# лейбл `sexually-explicit` в договоре стоит FORBIDDEN (quirks 00, таблица прав: deepseek §3.4(5),
# zai п. g) «Submitting … sexually explicit»). Отправлять им эротически плотную главу — нарушение
# ПРАВА, а не вопрос поведения модели. Поэтому из пула единиц снимаются ДВЕ главы с наибольшей
# плотностью эротических маркеров (верхний дециль по 18 главам); остаток и есть материал.
# Честная граница: после снятия остаётся ровно 16 глав на 16 единиц, то есть выбор «из середины
# длин» вырождается в «все оставшиеся». Это ограничение, а не замысел.
SE_MARKERS = ("炉鼎", "双修", "凌辱", "玉体", "娇躯", "呻吟", "", "私处", "交合",
"情欲", "欲望", "", "")
SE_DROP_TOP = 2
def _get(url: str) -> str:
r = subprocess.run(["curl", "-s", "--compressed", "-m", "60", "-A", UA, url], # noqa: S603, S607
capture_output=True, text=True)
return r.stdout
def _get_polite(url: str, tries: int = 4) -> str:
"""Площадка ставит капчу на быстрый обход. Отступаем и ждём, а не долбим: это чужой сервер.
Поймано исполнением: при паузе 0.4 с обход умер на 19-й главе редиректом на `/captcha`.
"""
for k in range(tries):
html = _get(url)
if "captcha" not in html[:200].lower():
return html
wait = 20 * (k + 1)
print(f" капча — пауза {wait} с")
time.sleep(wait)
return ""
def _chapter_text(html: str) -> tuple[str, str]:
"""(заголовок, текст) главы. Пусто — страница не глава."""
m = re.search(r'<div class="content"[^>]*>(.*?)</div>', html, re.S)
if not m:
return "", ""
body = re.sub(r"<br\s*/?>", "\n", m.group(1))
body = re.sub(r"</p>", "\n", body)
body = re.sub(r"<[^>]+>", "", body)
body = re.sub(r"&nbsp;", " ", body)
lines = [ln.strip() for ln in body.split("\n")]
t = re.sub(r"\n{2,}", "\n", "\n".join(ln for ln in lines if ln)).strip()
h = re.search(r"<title>\s*([^<_]+)", html)
return (h.group(1).strip() if h else ""), t
def crawl(limit: int = 200) -> None:
"""Скачать главы по цепочке «следующая глава». Идемпотентно: скачанное не перекачивается."""
SRC.mkdir(parents=True, exist_ok=True)
cid, n = str(BOOK["first_chapter"]), 0
while cid and cid != "0" and n < limit:
f = SRC / f"{n + 1:04d}-{cid}.json"
if f.exists():
nxt = json.loads(f.read_text(encoding="utf-8"))["next"]
else:
html = _get_polite(f"https://read.zongheng.com/chapter/{BOOK['book_id']}/{cid}.html")
title, text = _chapter_text(html)
if not text:
print(f" глава {cid}: текста нет — стоп")
break
m = re.search(rf'href="/chapter/{BOOK["book_id"]}/(\d+)\.html\?"[^>]*>\s*下一章', html)
nxt = m.group(1) if m else "0"
f.write_text(json.dumps(dict(idx=n + 1, cid=cid, title=title, next=nxt, text=text),
ensure_ascii=False), encoding="utf-8")
time.sleep(3.0)
n += 1
cid = nxt
tot = sum(len(json.loads(p.read_text(encoding="utf-8"))["text"]) for p in SRC.glob("*.json"))
print(f"глав на диске {len(list(SRC.glob('*.json')))} · знаков {tot:,}")
def chapters(all_: bool = False) -> list[dict]:
"""Главы среза. По умолчанию — БЕЗ снятых гейтом ToS (см. SE_DROP_TOP)."""
chs = [json.loads(p.read_text(encoding="utf-8")) for p in sorted(SRC.glob("*.json"))]
if all_ or not chs:
return chs
return [c for c in chs if c["idx"] not in se_dropped(chs)]
def se_density(text: str) -> int:
return sum(text.count(k) for k in SE_MARKERS)
def se_dropped(chs: list[dict] | None = None) -> set[int]:
"""Индексы глав, снятых гейтом прав: верхние SE_DROP_TOP по плотности маркеров."""
chs = chs if chs is not None else chapters(all_=True)
order = sorted(chs, key=lambda c: (-se_density(c["text"]) / max(1, len(c["text"])), c["idx"]))
return {c["idx"] for c in order[:SE_DROP_TOP]}
def uid_of(source: str) -> str:
return hashlib.sha1(source.strip().encode("utf-8")).hexdigest()[:10] # noqa: S324
_RE_CHAPTER = re.compile(r"^\s*第[零一二两三四五六七八九十百千\d]+[节章回]\s*[:]?\s*")
def _dedup_sig(source: str) -> str:
"""Подпись близких дублей — как в эксп-21: снимаются заголовок главы и все пробелы."""
s = _RE_CHAPTER.sub("", source.strip())
return hashlib.sha1("".join(s.split()).encode("utf-8")).hexdigest() # noqa: S324
def chunks() -> list[dict]:
"""Абзацные чанки ~1700 знаков. Абзац не рвётся: боевая сегментация тоже держит абзац.
⚠ Хвост главы КОРОЧЕ порога не выбрасывается, а прирастает к предыдущему чанку той же главы.
Первая версия его роняла и теряла ~30% текста книги — то есть меняла материал молча.
"""
out = []
for ch in chapters():
made: list[dict] = []
buf: list[str] = []
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"]))
elif len(made[-1]["text"]) + size <= MAX_CHARS:
made[-1]["text"] += "\n" + tail
else:
made.append(dict(text=tail, chapter=ch["idx"]))
out += made
return [c for c in out if MIN_CHARS <= len(c["text"]) <= MAX_CHARS]
def units_zh(n: int = N_ZH) -> list[dict]:
"""N единиц: дедуп по подписи, ключ — хеш источника, отбор из СЕРЕДИНЫ длин (эксп-21 §0).
Из середины — чтобы единицы были боевого размера и при этом не выбросами; ключ сортировки
(длина, uid), а не одна длина: при равной длине порядок иначе решает обход множества.
"""
uniq: dict[str, dict] = {}
seen: set[str] = set()
for c in chunks():
sig = _dedup_sig(c["text"])
if sig in seen:
continue
seen.add(sig)
uniq.setdefault(uid_of(c["text"]), dict(source=c["text"], uid=uid_of(c["text"]),
chapter=c["chapter"]))
keys = sorted(uniq, key=lambda k: (len(uniq[k]["source"]), k))
lo = (len(keys) - n) // 2
return [uniq[k] for k in keys[lo:lo + n]]
# ⚠ Единица `129b73ad5a` выброшена по §8 эксп-21: это выходные данные и библиография, а не проза.
EN_DROP = {"129b73ad5a"}
def units_en(n: int = N_EN) -> list[dict]:
"""Ось переноса: единицы пары en из эксп-21, минус выброшенная библиография.
Берутся ПЕРВЫЕ n по стабильному порядку (страта, длина, uid) — набор объявлен здесь, чтобы
«≥6» не превратилось в выбор после замера.
"""
import pair_en as PE # noqa: PLC0415
us = [u for u in PE.units() if u["uid"] not in EN_DROP]
us.sort(key=lambda u: (u["stratum"], len(u["source"]), u["uid"]))
fr = [u for u in us if u["stratum"] == "fr"][: n // 2]
pl = [u for u in us if u["stratum"] == "plain"][: n - n // 2]
return fr + pl
def manifest() -> dict:
zs, es = units_zh(), units_en()
return dict(
book={k: v for k, v in BOOK.items() if k != "first_chapter"},
zh=dict(n=len(zs), chars=[len(u["source"]) for u in zs],
uids=[u["uid"] for u in zs], chapters=[u["chapter"] for u in zs],
sha=[hashlib.sha256(u["source"].encode()).hexdigest()[:16] for u in zs]),
en=dict(n=len(es), uids=[u["uid"] for u in es],
strata=[u["stratum"] for u in es],
sha=[hashlib.sha256(u["source"].encode()).hexdigest()[:16] for u in es]),
)
def _pairwise_max_sim(us: list[dict]) -> float:
"""Максимальная попарная близость отобранных единиц — контроль дедупа (эксп-21 дал 0.060).
⚠ `ratio`, а не `quick_ratio`: последний игнорирует порядок и на CJK даёт 0.6+ у заведомо
разных глав (общие служебные иероглифы), то есть с числом эксп-21 несравним.
"""
import difflib # noqa: PLC0415
best = 0.0
for i, a in enumerate(us):
for b in us[i + 1:]:
best = max(best, difflib.SequenceMatcher(None, a["source"], b["source"]).ratio())
return best
def cmd_units() -> None:
zs, es = units_zh(), units_en()
allch = chapters(all_=True)
drop = se_dropped(allch)
print(f"КНИГА {BOOK['title']} · {BOOK['genre']} · 首发 {BOOK['first_pub']} · {BOOK['url']}")
print(f"глав скачано {len(allch)} · снято гейтом прав {sorted(drop)} "
f"(плотность эротических маркеров: "
f"{', '.join(f'гл.{c["idx"]}={se_density(c["text"])}' for c in allch if c['idx'] in drop)})")
print(f"главы в пуле {len(chapters())} · чанков {len(chunks())}")
print(f"\nzh-единиц {len(zs)} · знаков "
f"{min(len(u['source']) for u in zs)}{max(len(u['source']) for u in zs)} "
f"(медиана {sorted(len(u['source']) for u in zs)[len(zs) // 2]})")
print(f"глав-источников {len(set(u['chapter'] for u in zs))} "
f"· макс. попарная близость {_pairwise_max_sim(zs):.3f}")
for u in zs:
print(f" {u['uid']} гл.{u['chapter']:3d} {len(u['source']):5d} зн")
print(f"\nen-единиц {len(es)}: " + " · ".join(f"{u['uid']}({u['stratum']})" for u in es))
(OUT / "manifest.json").write_text(json.dumps(manifest(), ensure_ascii=False, indent=1),
encoding="utf-8")
print(f"\nманифест → {OUT / 'manifest.json'} (хеши единиц — во фриз пре-рега)")
if __name__ == "__main__":
OUT.mkdir(parents=True, exist_ok=True)
a = sys.argv[1:] or ["--units"]
if a[0] == "--crawl":
crawl()
elif a[0] == "--units":
cmd_units()
else:
print(__doc__)