Files
catalog_doc/catalogizer/summarize.py
Andrey Kruchinkin 49f91247c7 Каталогизатор документов: разбор папки, тезисы, поиск, GUI
Программа принимает папку с документами, извлекает из них текст и
метаданные и строит каталог: краткое описание, тезисы и ключевые слова
для каждого файла плюс поиск по всему собранному.

Ядро:
- extract: PDF, DJVU, DOC(X), RTF, ODT, CHM, EPUB, FB2, PPT(X), XLS(X), TXT;
  PDF передаётся MuPDF потоком в память (работают длинные пути) и ограничен
  по времени — повреждённый файл иначе чинится минутами;
- summarize: экстрактивное реферирование по TF-IDF, посчитанному на самой
  библиотеке, с лёгким стеммером для русского и английского;
- db: SQLite с полнотекстовым индексом FTS5, морфологический поиск с BM25;
- scanner: инкрементальный многопоточный обход, счётчик попыток защищает
  от файла, обрывающего разбор;
- report: автономный HTML с поиском, Markdown, JSON, CSV.

Интерфейс:
- окно PySide6 в тёмной теме: вкладки разбора и поиска, фоновый поток,
  карточка документа, правка своих ключевых слов;
- командная строка: build, scan, analyze, report, search, show, tag, stats;
- у каждой папки свой каталог, последняя обработанная запоминается.

Сборка: scripts/build_exe.py даёт Catalogizer.exe в Windows и
Catalogizer.app в macOS, иконки .ico и .icns рисуются кодом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 19:20:29 +03:00

179 lines
7.5 KiB
Python
Raw Permalink 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.
"""Ключевые слова, тезисы и краткое описание документа.
Без обращения к внешним сервисам: экстрактивное реферирование по TF-IDF,
рассчитанному на самой библиотеке.
"""
from __future__ import annotations
import math
import re
from collections import Counter
from . import config
from .textutil import (STOP, detect_lang, sentences, similar, stem, tokens)
# Общие для любой техлитературы слова: сами по себе ничего не говорят.
GENERIC = {
"устройство", "система", "систем", "работа", "работы", "значение", "число",
"рисунок", "таблица", "формула", "пример", "случай", "результат", "метод",
"задача", "часть", "время", "уровень", "величина", "процесс", "качество",
"device", "system", "value", "number", "example", "case", "result",
"method", "task", "part", "time", "level", "process", "quality", "user",
"chapter", "figure", "section", "information", "application", "data",
}
GENERIC_STEMS = {stem(w) for w in GENERIC}
WORD_OK = re.compile(r"^[a-zа-я][a-zа-я0-9\-]{2,}$")
def doc_terms(text: str, title: str = "", toc: list[str] | None = None) -> Counter:
"""Частоты основ слов документа с усилением заголовка и оглавления."""
c: Counter = Counter()
for w in tokens(text):
if WORD_OK.match(w):
c[stem(w)] += 1
for w in tokens(title):
if WORD_OK.match(w):
c[stem(w)] += 6
for line in (toc or [])[:120]:
for w in tokens(line):
if WORD_OK.match(w):
c[stem(w)] += 3
return c
def _surface_forms(text: str, title: str, toc: list[str] | None) -> dict[str, str]:
"""Для каждой основы — самая частая словоформа (её и показываем)."""
forms: dict[str, Counter] = {}
for src in (title, "\n".join(toc or []), text[:15_000]):
for w in tokens(src):
if WORD_OK.match(w):
forms.setdefault(stem(w), Counter())[w] += 1
best = {}
for st, c in forms.items():
top = c.most_common(1)[0][1]
# Среди частых словоформ берём самую короткую: «вход», а не «входами».
good = [w for w, n in c.items() if n >= top * 0.5]
best[st] = min(good, key=lambda w: (len(w), w))
return best
class Analyzer:
"""Считает ключевые слова и тезисы с учётом IDF по всей библиотеке."""
def __init__(self, df: dict[str, int], n_docs: int):
self.df = df
self.n_docs = max(n_docs, 1)
def idf(self, term: str) -> float:
return math.log((self.n_docs + 1) / (self.df.get(term, 0) + 1)) + 1.0
def weights(self, terms: Counter) -> dict[str, float]:
"""Вес основы = сглаженный TF × IDF, без слишком общих слов."""
total = sum(terms.values()) or 1
w = {}
for t, n in terms.items():
if t in GENERIC_STEMS or t in STOP or len(t) < 3:
continue
if n < 2 and total > 400:
continue
w[t] = (1 + math.log(n)) * self.idf(t) / math.log(total + 10)
return w
def keywords(self, terms: Counter, text: str, title: str,
toc: list[str] | None, limit: int = config.N_KEYWORDS) -> list[str]:
w = self.weights(terms)
forms = _surface_forms(text, title, toc)
best = sorted(w.items(), key=lambda kv: -kv[1])
out: list[str] = []
for st, _ in best:
word = forms.get(st, st)
if any(word in o or o in word for o in out):
continue
out.append(word)
if len(out) >= limit:
break
return out
def theses(self, text: str, terms: Counter,
limit: int = config.N_THESES) -> list[str]:
"""Экстрактивные тезисы: самые «плотные» по ключевым словам фразы."""
w = self.weights(terms)
cands = sentences(text)
if not cands:
return []
scored = []
for pos, s in enumerate(cands):
ws = [w.get(stem(t), 0.0) for t in tokens(s)]
if len(ws) < 5:
continue
score = sum(ws) / math.sqrt(len(ws))
# Начало документа (аннотация, введение) — обычно самое ценное.
score *= 1.0 + 0.4 * max(0.0, 1.0 - pos / 40.0)
if re.search(r"(предназначен|рассматрива|описыва|посвящ|позволяет"
r"|книга|пособие|руководство|describes|presents"
r"|provides|introduces|this (book|guide|manual))",
s, re.I):
score *= 1.35
scored.append((score, pos, s))
scored.sort(key=lambda x: -x[0])
picked: list[tuple[int, str]] = []
for _, pos, s in scored:
if any(similar(s, p) > 0.55 for _, p in picked):
continue
picked.append((pos, s))
if len(picked) >= limit:
break
picked.sort(key=lambda x: x[0])
return [s for _, s in picked]
TYPE_HINTS = (
(r"datasheet|data sheet|техническое описание", "даташит"),
(r"reference manual|user manual|руководство пользователя", "руководство"),
(r"application note|прикладн", "апноут"),
(r"учебн|пособие|курс лекций|лекци", "учебник"),
(r"диссертац|автореферат", "диссертация"),
(r"гост|стандарт|iec |ieee std", "стандарт"),
(r"статья|journal|proceedings", "статья"),
(r"errata|release notes", "заметки к выпуску"),
)
def guess_kind(title: str, text: str, pages: int | None) -> str:
"""Тип издания — по названию, первым страницам и объёму."""
probe = (title + " " + text[:3000]).lower()
for pat, kind in TYPE_HINTS:
if re.search(pat, probe):
return kind
if pages and pages > 120:
return "книга"
if pages and pages <= 20:
return "заметка"
return "документ"
def describe(title: str, kind: str, lang: str, pages: int | None,
keywords: list[str], theses: list[str], has_text: bool) -> str:
"""Краткое описание в одну-две фразы."""
head = kind.capitalize()
if lang in ("ru", "en"):
head += " на %s языке" % ("русском" if lang == "ru" else "английском")
if pages:
head += ", %d с" % pages
if keywords:
head += ". Темы: " + ", ".join(keywords[:6])
if not has_text:
return head + ". Текстовый слой отсутствует (скан) — описание по имени файла."
if theses:
first = theses[0]
if len(first) > 220:
first = first[:217].rsplit(" ", 1)[0] + ""
head += ". " + first
return head + ("" if head.endswith((".", "")) else ".")
def analyze_language(text: str) -> str:
return detect_lang(text)