"""Ключевые слова, тезисы и краткое описание документа. Без обращения к внешним сервисам: экстрактивное реферирование по 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)