feat: complete academic symbol lexicon heal, 2-column Cooper extraction, and updated assets
This commit is contained in:
Executable
+405
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
build_external_symbols_and_ingest.py
|
||||
====================================
|
||||
1. Парсит 4 словаря из папки dictionaries/:
|
||||
- Tresidder_Slovar-simvolov.3jnU_.fb2 (FB2, CP1251)
|
||||
- словарь символов.chm (CHM / HTML, CP1251)
|
||||
- Кирло Х. - Словарь символов... 2010.pdf (PDF, CP1251 over Latin1)
|
||||
- Чунакова О.М. - Пехлевийский словарь... 2004.pdf (PDF, UTF-8)
|
||||
2. Для символов, входящих в 1000 знаков Оракула:
|
||||
- Обогащает ТОЛЬКО тестовую базу: assets/data/oracle_passports_staging.json
|
||||
3. Для ВСЕХ символов, НЕ входящих в 1000:
|
||||
- Сохраняет в отдельную базу внешних символов:
|
||||
assets/data/oracle_external_symbols.json
|
||||
oracle_enricher/data/oracle_external_symbols.json
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Set
|
||||
from datetime import datetime
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from oracle_enricher.enricher.passport_schema import (
|
||||
PROMPTS_78,
|
||||
count_filled_fields,
|
||||
render_passport_markdown,
|
||||
)
|
||||
|
||||
DICT_DIR = PROJECT_ROOT / "dictionaries"
|
||||
STAGING_DB_PATH = PROJECT_ROOT / "assets" / "data" / "oracle_passports_staging.json"
|
||||
EXTERNAL_DB_PATH = PROJECT_ROOT / "assets" / "data" / "oracle_external_symbols.json"
|
||||
ENRICHER_EXTERNAL_PATH = PROJECT_ROOT / "oracle_enricher" / "data" / "oracle_external_symbols.json"
|
||||
CHM_EXTRACTED_DIR = PROJECT_ROOT / "oracle_enricher" / "data" / "harvest_cache" / "chm_extracted"
|
||||
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
HAS_FITZ = True
|
||||
except ImportError:
|
||||
HAS_FITZ = False
|
||||
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
HAS_BS4 = True
|
||||
except ImportError:
|
||||
HAS_BS4 = False
|
||||
|
||||
|
||||
def clean_spaces(text: str) -> str:
|
||||
"""Нормализует пробелы и переносы строк."""
|
||||
return re.sub(r'\s+', ' ', text).strip()
|
||||
|
||||
|
||||
def parse_tresidder_all() -> Dict[str, Dict[str, Any]]:
|
||||
"""Парсит Джека Тресиддера из FB2 и CHM."""
|
||||
print("📖 Парсинг Джека Тресиддера («Словарь символов»)...")
|
||||
results: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# 1. Из FB2
|
||||
fb2_path = DICT_DIR / "Tresidder_Slovar-simvolov.3jnU_.fb2"
|
||||
if fb2_path.exists():
|
||||
try:
|
||||
with open(fb2_path, "rb") as f:
|
||||
raw = f.read().decode("windows-1251", errors="ignore")
|
||||
paragraphs = re.findall(r'<p>(.*?)</p>', raw, re.DOTALL)
|
||||
cur_h, cur_l = None, []
|
||||
for p in paragraphs:
|
||||
clean = re.sub(r'<[^>]+>', '', p).strip()
|
||||
if clean.isupper() and 2 <= len(clean) <= 40 and not any(skip in clean for skip in ['ПРЕДИСЛОВИЕ', 'ГЛАВА', 'СЛОВАРЬ', 'ЛИТЕРАТУРА', 'УКАЗАТЕЛЬ']):
|
||||
if cur_h and cur_l:
|
||||
term_key = cur_h.strip().lower()
|
||||
body = clean_spaces(' '.join(cur_l))
|
||||
if len(body) > 20:
|
||||
results[term_key] = {
|
||||
"term": cur_h.strip(),
|
||||
"author": "Джек Тресиддер",
|
||||
"work": "«Словарь символов»",
|
||||
"text": body,
|
||||
}
|
||||
cur_h = clean
|
||||
cur_l = []
|
||||
else:
|
||||
if cur_h and clean:
|
||||
cur_l.append(clean)
|
||||
if cur_h and cur_l:
|
||||
term_key = cur_h.strip().lower()
|
||||
body = clean_spaces(' '.join(cur_l))
|
||||
if len(body) > 20:
|
||||
results[term_key] = {
|
||||
"term": cur_h.strip(),
|
||||
"author": "Джек Тресиддер",
|
||||
"work": "«Словарь символов»",
|
||||
"text": body,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f" [Ошибка FB2: {e}]")
|
||||
|
||||
# 2. Дополняем из распакованного CHM (если есть)
|
||||
if CHM_EXTRACTED_DIR.exists() and HAS_BS4:
|
||||
for p in CHM_EXTRACTED_DIR.glob("*.php"):
|
||||
try:
|
||||
with open(p, "r", encoding="windows-1251", errors="ignore") as f:
|
||||
text = BeautifulSoup(f.read(), "html.parser").get_text()
|
||||
m = re.search(r'([А-ЯЁ\s\-]{2,40})\s*\n\s*из книги СЛОВАРЬ СИМВОЛОВ', text)
|
||||
if m:
|
||||
head = m.group(1).strip()
|
||||
parts = text.split("Тресиддер")
|
||||
if len(parts) > 1:
|
||||
body = clean_spaces(parts[1].split("<<назад")[0])
|
||||
term_key = head.lower()
|
||||
if head and body and term_key not in results and len(body) > 20:
|
||||
results[term_key] = {
|
||||
"term": head,
|
||||
"author": "Джек Тресиддер",
|
||||
"work": "«Словарь символов»",
|
||||
"text": body,
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
print(f" ✓ Собрано статей Тресиддера: {len(results)}")
|
||||
return results
|
||||
|
||||
|
||||
def parse_cirlot_pdf() -> Dict[str, Dict[str, Any]]:
|
||||
"""Парсит Хуана Эдуардо Керлота («Словарь символов: 1000 статей...»)."""
|
||||
print("📖 Парсинг Х.Э. Керлота («Словарь символов: 1000 статей...»)...")
|
||||
results: Dict[str, Dict[str, Any]] = {}
|
||||
pdf_path = DICT_DIR / "Кирло Х. - Словарь символов. 1000 статей о важнейших понятиях религии, литературы, архитектуры, истории - 2010.pdf"
|
||||
|
||||
if not pdf_path.exists():
|
||||
print(f" [Файл не найден: {pdf_path.name}]")
|
||||
return results
|
||||
|
||||
try:
|
||||
# Извлекаем текст через pdftotext и декодируем CP1251-over-Latin1
|
||||
cmd = ["pdftotext", "-f", "52", "-l", "524", str(pdf_path), "-"]
|
||||
out = subprocess.check_output(cmd)
|
||||
txt = out.decode("utf-8", errors="ignore").encode("latin1", errors="ignore").decode("cp1251", errors="ignore")
|
||||
|
||||
lines = txt.splitlines()
|
||||
cur_h, cur_l = None, []
|
||||
|
||||
for line in lines:
|
||||
s = line.strip()
|
||||
if not s:
|
||||
continue
|
||||
if s.isupper() and 2 <= len(s) <= 35 and re.match(r'^[А-ЯЁ\s\-]+$', s) and not re.search(r'\d', s):
|
||||
if s in ['СЛОВАРЬ СИМВОЛОВ', 'СОДЕРЖАНИЕ', 'ВВЕДЕНИЕ', 'АЛФАВИТНЫЙ УКАЗАТЕЛЬ']:
|
||||
continue
|
||||
if cur_h and cur_l:
|
||||
term_key = cur_h.strip().lower()
|
||||
body = clean_spaces(' '.join(cur_l))
|
||||
if len(body) > 20:
|
||||
results[term_key] = {
|
||||
"term": cur_h.strip(),
|
||||
"author": "Х.Э. Керлот",
|
||||
"work": "«Словарь символов»",
|
||||
"text": body,
|
||||
}
|
||||
cur_h = s
|
||||
cur_l = []
|
||||
else:
|
||||
if cur_h and not s.isdigit():
|
||||
cur_l.append(s)
|
||||
|
||||
if cur_h and cur_l:
|
||||
term_key = cur_h.strip().lower()
|
||||
body = clean_spaces(' '.join(cur_l))
|
||||
if len(body) > 20:
|
||||
results[term_key] = {
|
||||
"term": cur_h.strip(),
|
||||
"author": "Х.Э. Керлот",
|
||||
"work": "«Словарь символов»",
|
||||
"text": body,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f" [Ошибка при парсинге Керлота: {e}]")
|
||||
|
||||
print(f" ✓ Собрано статей Керлота: {len(results)}")
|
||||
return results
|
||||
|
||||
|
||||
def parse_chunakova_pdf() -> Dict[str, Dict[str, Any]]:
|
||||
"""Парсит словарь О.М. Чунаковой (Пехлевийский словарь зороастрийских терминов)."""
|
||||
print("📖 Парсинг О.М. Чунаковой («Пехлевийский словарь зороастрийских терминов»)...")
|
||||
results: Dict[str, Dict[str, Any]] = {}
|
||||
pdf_path = DICT_DIR / "Чунакова О.М. - Пехлевийский словарь зороастрийских терминов, мифических персонажей и мифологических символов. M. 2004.pdf"
|
||||
|
||||
if not pdf_path.exists() or not HAS_FITZ:
|
||||
print(f" [Файл или PyMuPDF не найден]")
|
||||
return results
|
||||
|
||||
try:
|
||||
doc = fitz.open(str(pdf_path))
|
||||
cur_h, cur_l = None, []
|
||||
|
||||
for p in range(12, 285):
|
||||
t = doc[p].get_text()
|
||||
for line in t.splitlines():
|
||||
s = line.strip()
|
||||
if not s or s.isdigit():
|
||||
continue
|
||||
m = re.match(r'^([А-ЯЁ][а-яёА-ЯЁ\s\-]{1,35})\s*[—–-]\s*(пехл\.|авест\.|«|[А-ЯЁа-яё])', s)
|
||||
if m and not any(skip in m.group(1).strip() for skip in ['Как', 'В', 'По', 'На', 'При', 'От', 'С', 'Для', 'Из']):
|
||||
head = m.group(1).strip()
|
||||
if cur_h and cur_l:
|
||||
term_key = cur_h.strip().lower()
|
||||
body = clean_spaces(' '.join(cur_l))
|
||||
if len(body) > 20:
|
||||
results[term_key] = {
|
||||
"term": cur_h.strip(),
|
||||
"author": "О.М. Чунакова",
|
||||
"work": "«Пехлевийский словарь зороастрийских терминов»",
|
||||
"text": body,
|
||||
}
|
||||
cur_h = head
|
||||
cur_l = [s]
|
||||
else:
|
||||
if cur_h:
|
||||
cur_l.append(s)
|
||||
|
||||
if cur_h and cur_l:
|
||||
term_key = cur_h.strip().lower()
|
||||
body = clean_spaces(' '.join(cur_l))
|
||||
if len(body) > 20:
|
||||
results[term_key] = {
|
||||
"term": cur_h.strip(),
|
||||
"author": "О.М. Чунакова",
|
||||
"work": "«Пехлевийский словарь зороастрийских терминов»",
|
||||
"text": body,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f" [Ошибка при парсинге Чунаковой: {e}]")
|
||||
|
||||
print(f" ✓ Собрано статей Чунаковой: {len(results)}")
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 75)
|
||||
print("🔮 ОБОГАЩЕНИЕ ТЕСТОВОЙ БАЗЫ И ФОРМИРОВАНИЕ БАЗЫ ВНЕШНИХ СИМВОЛОВ")
|
||||
print("=" * 75)
|
||||
|
||||
# 1. Загрузка тестовой базы (staging)
|
||||
if not STAGING_DB_PATH.exists():
|
||||
print(f"[ERROR] Тестовая база не найдена: {STAGING_DB_PATH}")
|
||||
return
|
||||
|
||||
with open(STAGING_DB_PATH, "r", encoding="utf-8") as f:
|
||||
staging_db = json.load(f)
|
||||
|
||||
# Индекс 1000 знаков
|
||||
core_index = {}
|
||||
for idx_str, entry in staging_db.items():
|
||||
w = entry.get("word", "").lower().strip()
|
||||
if w:
|
||||
core_index[w] = idx_str
|
||||
|
||||
print(f"База канона 1000 знаков загружена: {len(core_index)} слов.")
|
||||
|
||||
# 2. Загрузка существующей базы внешних символов (если уже есть)
|
||||
external_db: Dict[str, Dict[str, Any]] = {}
|
||||
if EXTERNAL_DB_PATH.exists():
|
||||
try:
|
||||
with open(EXTERNAL_DB_PATH, "r", encoding="utf-8") as f:
|
||||
external_db = json.load(f)
|
||||
print(f"Существующая база внешних символов загружена: {len(external_db)} записей.")
|
||||
except Exception:
|
||||
external_db = {}
|
||||
|
||||
# 3. Парсинг словарей
|
||||
t_tresidder = parse_tresidder_all()
|
||||
t_cirlot = parse_cirlot_pdf()
|
||||
t_chunakova = parse_chunakova_pdf()
|
||||
|
||||
all_books = [
|
||||
("cirlot", t_cirlot),
|
||||
("tresidder", t_tresidder),
|
||||
("chunakova", t_chunakova),
|
||||
]
|
||||
|
||||
matched_staging_count = 0
|
||||
new_external_count = 0
|
||||
timestamp_str = datetime.now().isoformat()
|
||||
|
||||
for book_id, book_entries in all_books:
|
||||
print(f"\nОбработка источника: {book_id} ({len(book_entries)} статей)...")
|
||||
injected_this_book = 0
|
||||
external_this_book = 0
|
||||
|
||||
for norm_term, item in book_entries.items():
|
||||
author = item["author"]
|
||||
work = item["work"]
|
||||
full_text = item["text"]
|
||||
citation = f"[{author} · {work}]"
|
||||
|
||||
# Короткая выжимка для цитирования
|
||||
excerpt = (full_text[:380] + "...") if len(full_text) > 380 else full_text
|
||||
quoted_text = f"{citation}: «{excerpt}»"
|
||||
|
||||
# Проверяем вхождение в 1000 канонических знаков
|
||||
# Прямое совпадение или через форму единственного числа (напр. 'ангелы' -> 'ангел')
|
||||
matched_idx = None
|
||||
if norm_term in core_index:
|
||||
matched_idx = core_index[norm_term]
|
||||
elif norm_term.endswith("ы") and norm_term[:-1] in core_index:
|
||||
matched_idx = core_index[norm_term[:-1]]
|
||||
elif norm_term.endswith("и") and norm_term[:-1] in core_index:
|
||||
matched_idx = core_index[norm_term[:-1]]
|
||||
|
||||
if matched_idx:
|
||||
# ОБОГАЩАЕМ ТЕСТОВУЮ БАЗУ (STAGING)
|
||||
entry = staging_db[matched_idx]
|
||||
fields = entry.setdefault("fields", {})
|
||||
|
||||
field_target = None
|
||||
if book_id == "cirlot":
|
||||
if not fields.get("meta_synthesis"):
|
||||
field_target = "meta_synthesis"
|
||||
elif not fields.get("culture_hermetic_light"):
|
||||
field_target = "culture_hermetic_light"
|
||||
elif book_id == "tresidder":
|
||||
if not fields.get("semantic_core"):
|
||||
field_target = "semantic_core"
|
||||
elif not fields.get("culture_antiquity_light"):
|
||||
field_target = "culture_antiquity_light"
|
||||
elif book_id == "chunakova":
|
||||
if not fields.get("culture_persia_light"):
|
||||
field_target = "culture_persia_light"
|
||||
elif not fields.get("script_persia"):
|
||||
field_target = "script_persia"
|
||||
|
||||
if field_target and not fields.get(field_target):
|
||||
fields[field_target] = quoted_text
|
||||
injected_this_book += 1
|
||||
matched_staging_count += 1
|
||||
filled, _ = count_filled_fields(entry)
|
||||
entry["filled_count"] = filled
|
||||
entry["is_complete"] = (filled >= 78)
|
||||
|
||||
else:
|
||||
# СИМВОЛ НЕ ВХОДИТ В 1000 -> ПИШЕМ В БАЗУ ВНЕШНИХ СИМВОЛОВ!
|
||||
if norm_term not in external_db:
|
||||
external_db[norm_term] = {
|
||||
"term": item["term"],
|
||||
"normalized": norm_term,
|
||||
"category_guess": "Внешний академический символ",
|
||||
"sources": [],
|
||||
"created_at": timestamp_str,
|
||||
"updated_at": timestamp_str,
|
||||
}
|
||||
new_external_count += 1
|
||||
external_this_book += 1
|
||||
|
||||
# Проверяем, нет ли уже источника от этого автора
|
||||
existing_sources = external_db[norm_term]["sources"]
|
||||
if not any(s.get("author") == author for s in existing_sources):
|
||||
existing_sources.append({
|
||||
"author": author,
|
||||
"work": work,
|
||||
"excerpt": excerpt,
|
||||
"full_text": full_text,
|
||||
})
|
||||
external_db[norm_term]["updated_at"] = timestamp_str
|
||||
|
||||
print(f" -> В тестовую базу обогащено знаков: {injected_this_book}")
|
||||
print(f" -> В базу внешних символов добавлено новых понятий: {external_this_book}")
|
||||
|
||||
# 4. Сохраняем обновленную тестовую базу
|
||||
print(f"\n💾 Сохранение тестовой базы: {STAGING_DB_PATH}...")
|
||||
with open(STAGING_DB_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(staging_db, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 5. Сохраняем базу внешних символов (в assets и в enricher)
|
||||
print(f"💾 Сохранение базы внешних символов: {EXTERNAL_DB_PATH}...")
|
||||
EXTERNAL_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(EXTERNAL_DB_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(external_db, f, ensure_ascii=False, indent=2)
|
||||
|
||||
ENRICHER_EXTERNAL_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(ENRICHER_EXTERNAL_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(external_db, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# Статистика
|
||||
total_fields_staging = sum(e.get("filled_count", 0) for e in staging_db.values())
|
||||
print("\n" + "=" * 75)
|
||||
print("📊 ИТОГОВЫЙ ОТЧЕТ:")
|
||||
print(f"• Всего внешних символов в реестре: {len(external_db):,} понятий")
|
||||
print(f"• Обогащено ячеек в тестовой базе (staging): +{matched_staging_count}")
|
||||
print(f"• Общая заполненность тестовой базы: {total_fields_staging:,} / 78,000 ячеек")
|
||||
print(f"• Файл внешних символов: {EXTERNAL_DB_PATH} ({EXTERNAL_DB_PATH.stat().st_size / (1024*1024):.2f} MB)")
|
||||
print("=" * 75)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user