""" guards.py — механизмы цитирования сервиса «Сказковедение». ЭТОТ МОДУЛЬ ПУБЛИКУЕТСЯ (п. 7.10 ТУ). Он существует затем, чтобы любой — пользователь, правообладатель, проверяющий — мог убедиться, что заявленные в разделе 7 Условий ограничения действительно реализованы, а не описаны на словах. Отсюда три требования к коду ниже: 1. Каждый механизм ссылается на пункт договора, который исполняет. 2. Никаких обходных путей: нет параметра, флага или режима, отключающего обрезку, атрибуцию или пометку OCR. 3. Сериализация ответа — по явному белому списку полей. Передача объекта из хранилища наружу целиком запрещена: иначе новое поле, добавленное когда-нибудь в индексатор, утечёт само собой. Что здесь СОЗНАТЕЛЬНО отсутствует и почему — см. раздел 0 README набора. Кратко: скользящие окна, лимиты по знакам за период, запрет смежных фрагментов и доли от объёма файла удалены как неэффективные против недобросовестного использования (полный текст всё равно доступен по выдаваемой нами же ссылке) и вредные для добросовестного. """ import sqlite3 import time from collections import deque from dataclasses import dataclass, field from threading import Lock from typing import Optional import config # ============================================================ # РЕЗУЛЬТАТ ПРОВЕРКИ # ============================================================ @dataclass class GuardVerdict: """Что делать с одним результатом поиска. Три исхода, а не два. Помимо «выдать» и «не выдать» есть третий, прямо предусмотренный п. 7.5 ТУ: бюджет источника исчерпан, но «Атрибуция и сведения об Источнике продолжают предоставляться». Пользователь узнаёт, что источник по теме существует, и может обратиться к нему сам. """ allow_fragment: bool allow_attribution: bool guard: str # для журнала; 'ok' если ничего не сработало reason: Optional[str] = None # человекочитаемое, уходит пользователю ALLOW = GuardVerdict(True, True, 'ok') # ============================================================ # G1 — обрезка Фрагмента (п. 7.2 ТУ) # ============================================================ def trim_fragment(text: str, limit: int = None) -> str: """Не более FRAGMENT_MAX_CHARS знаков, с обрезкой по границе предложения. Обрезаем по последней найденной границе предложения в пределах лимита. Если границы нет вовсе (сплошной текст без пунктуации — обычное дело для плохого OCR), режем жёстко и ставим многоточие: лимит важнее красоты. """ limit = limit or config.FRAGMENT_MAX_CHARS text = (text or '').strip() if len(text) <= limit: return text window = text[:limit] cut = max((window.rfind(e) for e in config.SENTENCE_ENDINGS), default=-1) # Слишком ранняя граница (первая треть) означала бы, что мы выбросили # большую часть допустимого объёма ради одной точки в начале. if cut > limit // 3: return window[:cut + 1].strip() return window.rstrip() + '…' # ============================================================ # G2 — атрибуция и пометка OCR (п. 7.3 ТУ) # ============================================================ OCR_NOTICE = ( '[Текст распознан автоматически (OCR) и может содержать ошибки. ' 'Не использовать как точную цитату без сверки с оригиналом.]' ) LOCATOR_FIELDS = ('doi_or_url', 'holding_institution', 'imprint', 'journal') def has_attribution(source: dict) -> bool: """Стандарт атрибуции (п. 7.3 ТУ): заглавие + ответственность (автор, составитель, редактор или издающая организация — в поле author уже с пометкой роли) + хотя бы один способ найти оригинал для сверки: DOI/карточка, место хранения, выходные данные или издание. Год не обязателен: у части архивных материалов он не восстанавливается в принципе (в выдаче — «б. г.»). Полнота атрибуции обеспечивается на стороне сбора (attribution.py); здесь — последний рубеж, повторяющий то же правило, чтобы неполная запись не ушла наружу ни при каких обстоятельствах. """ if not source: return False filled = lambda f: bool((source.get(f) or '').strip()) return filled('author') and filled('title') and any(filled(f) for f in LOCATOR_FIELDS) def apply_ocr_notice(fragment: str, source: dict) -> str: """Пометка вставляется в тот же объект, что и текст Фрагмента. Не отдельным полем ответа — намеренно. Отдельное поле клиент может не показать или отбросить, и тогда п. 7.3 ТУ («пометка не может быть отключена») оказался бы неисполненным при формально работающем коде. """ if (source.get('text_extraction_method') or '') == 'ocr': return f'{fragment}\n\n{OCR_NOTICE}' return fragment # ============================================================ # G4 — ограничение частоты (п. 7.7 ТУ) # ============================================================ class RateLimiter: """Скользящее окно в памяти процесса. В SQLite не пишется намеренно: ограничение частоты — защита от скриптов, а не предмет учёта, и писать в базу на каждую отклонённую попытку значило бы дать простейший способ раздуть её обращениями. Единственный уцелевший механизм против автоматизированной выгрузки. Всё остальное из прежней редакции удалено как неработающее. """ def __init__(self, limit: int = None, window: int = None, burst: int = None): self.limit = limit or config.RATE_LIMIT self.window = window or config.RATE_WINDOW_SECONDS self.burst = burst or config.RATE_BURST self._hits: dict[str, deque] = {} self._lock = Lock() def check(self, token: str) -> bool: """True — можно. False — превышение.""" now = time.time() with self._lock: q = self._hits.setdefault(token, deque()) while q and now - q[0] > self.window: q.popleft() if len(q) >= self.burst: return False if len(q) >= self.limit: return False q.append(now) return True def cleanup(self): """Убрать токены, давно не обращавшиеся. Иначе словарь растёт вечно.""" now = time.time() with self._lock: for tok in [t for t, q in self._hits.items() if not q or now - q[-1] > self.window * 10]: del self._hits[tok] # ============================================================ # G7 — СТРОКА СОВПАДЕНИЯ (п. 7.4 ТУ) # ============================================================ # Функция перечня ФИЗИЧЕСКИ не получает сведений об источнике: на входе только # текст куска и запрос. Так граница стадий держится устройством кода, а не # аккуратностью того, кто его правит. import re as _re # 17.10.2026: раньше «<[^>]+>» съедал редакторские названия «<Царевна-лягушка>» (в строке оставалось «« »»), # а «[#*_>|]+» — значимые звёздочки шифров (СУС 480 А*). Теперь тег — только латинский (,

), # разметка — двойные **, любые _, # и > в начале строки, |. _SERVICE = _re.compile(r'---\s*стр\.\s*\d+[^\n]*?---|]*>|\*{2,}|_+|(?m:^)[ \t]*[#>]+|\|+' r'|Digitized\s+by\s+Go\w*', _re.I) _WORD = _re.compile(r'[A-Za-zА-Яа-яЁёІіѢѣЪъ]{3,}') _QTOK = _re.compile(r'[A-Za-zА-Яа-яЁёІіѢѣЪъ]{3,}|\d+[A-Za-zА-Яа-я*]*') _SENT = _re.compile(r'(?<=[.!?…])\s+(?=[«"(A-ZА-ЯЁ0-9—–-])') def clean_text(text: str) -> str: """Снять служебное: маркеры страниц, разметку, «Digitized by Google».""" return _re.sub(r'\s+', ' ', _SERVICE.sub(' ', text or '')).strip() def _stems(query: str) -> list: out = [] for t in _QTOK.findall((query or '').lower()): out.append(t if t[0].isdigit() else t[:5]) return list(dict.fromkeys(out)) def build_match_line(text: str, query: str) -> str: """Одно предложение или его часть, не длиннее MATCH_LINE_MAX_CHARS, вокруг найденного. Выбирается предложение с наибольшим числом слов запроса; при равенстве — первое.""" limit = config.MATCH_LINE_MAX_CHARS s = clean_text(text) if not s: return '' stems = _stems(query) best, best_score, best_pos = None, -1, 0 for sent in _SENT.split(s): low = sent.lower() hits = [low.find(st) for st in stems if st in low] if len(hits) > best_score: best, best_score = sent, len(hits) best_pos = min(hits) if hits else 0 line = best.strip() if len(line) <= limit: return line # Окно вокруг первого совпадения, по границам слов. start = max(0, best_pos - limit // 3) if start: sp = line.find(' ', start) start = sp + 1 if 0 <= sp < best_pos else start piece = line[start:start + limit - 2] if start + len(piece) < len(line): cut = piece.rfind(' ') piece = (piece[:cut] if cut > limit // 2 else piece).rstrip() + '…' if start: piece = '…' + piece return piece[:limit] # ============================================================ # ПРОВЕРКА ОДНОГО РЕЗУЛЬТАТА: G5 -> G2 -> G3 # ============================================================ def check_result(db: sqlite3.Connection, token: str, epoch: int, doc_id: str, source: Optional[dict]) -> GuardVerdict: """Порядок проверок не случаен и менять его нельзя. G5 (стоп-лист) идёт первым: заблокированный источник исчезает целиком, включая атрибуцию. Если бы он шёл после G3, исчерпание бюджета уже успело бы раскрыть сам факт существования источника в корпусе. """ # --- G5: стоп-лист правообладателя (п. 9.2 ТУ) --- if source is None: return GuardVerdict(False, False, 'unknown_source') if source.get('blocked'): return GuardVerdict(False, False, 'blocked') # --- G2: атрибуция (п. 7.3 ТУ) --- if not has_attribution(source): return GuardVerdict(False, False, 'no_attribution') # --- G3: бюджет источника (п. 7.5 ТУ) --- row = db.execute( 'SELECT fragments FROM source_budget WHERE token=? AND doc_id=? AND epoch=?', (token, doc_id, epoch) ).fetchone() used = row[0] if row else 0 if used >= config.SOURCE_BUDGET: return GuardVerdict( False, True, 'budget', reason=(f'По этому источнику выбран лимит фрагментов ' f'({config.SOURCE_BUDGET}). Сведения об источнике доступны; ' f'лимит обнулится при приобретении нового пакета.') ) return ALLOW def consume_budget(db: sqlite3.Connection, token: str, epoch: int, doc_id: str, count: int = 1): """Списание бюджета. UPSERT — чтобы не проверять существование строки. Расположение Фрагмента в тексте не учитывается: продолжение найденного отрывка стоит столько же, сколько любой другой Фрагмент. Это и есть замена прежнему запрету смежных фрагментов, который мешал добросовестной работе и не мешал недобросовестной. """ db.execute( 'INSERT INTO source_budget (token, doc_id, epoch, fragments) ' 'VALUES (?,?,?,?) ' 'ON CONFLICT(token, doc_id, epoch) ' 'DO UPDATE SET fragments = fragments + excluded.fragments', (token, doc_id, epoch, count) ) def budget_left(db: sqlite3.Connection, token: str, epoch: int, doc_id: str) -> int: row = db.execute( 'SELECT fragments FROM source_budget WHERE token=? AND doc_id=? AND epoch=?', (token, doc_id, epoch) ).fetchone() return max(0, config.SOURCE_BUDGET - (row[0] if row else 0)) # ============================================================ # G6 — журнал (п. 7.9 ТУ) # ============================================================ def log_access(db: sqlite3.Connection, token: str, doc_id: Optional[str], chars: Optional[int], guard: str): """ТЕКСТ ЗАПРОСА И ТЕКСТ ФРАГМЕНТА СЮДА НЕ ПОПАДАЮТ. У функции нет и не должно появиться параметра для них. Это прямое обязательство перед пользователем (п. 7.9 ТУ), и добавленное «на время отладки» поле сделало бы заявление в договоре ложным. """ db.execute( 'INSERT INTO access_log (token, ts, doc_id, chars, guard) VALUES (?,?,?,?,?)', (token, int(time.time()), doc_id, chars, guard) ) def purge_old_logs(db: sqlite3.Connection) -> int: """Ежесуточная очистка. Срок хранения назван в договоре, значит хранить дольше — тоже нарушение, не только меньше.""" cutoff = int(time.time()) - config.LOG_RETENTION_DAYS * 86400 cur = db.execute('DELETE FROM access_log WHERE ts < ?', (cutoff,)) return cur.rowcount # ============================================================ # СЕРИАЛИЗАЦИЯ ПО БЕЛОМУ СПИСКУ # ============================================================ ATTRIBUTION_FIELDS = ('author', 'title', 'year', 'journal', 'imprint', 'doi_or_url', 'holding_institution', 'bibliographic_description') # Никогда не покидают сервер. Список для теста приёмки, который проверяет # ответ на их отсутствие механически, а не глазами по коду. FORBIDDEN_FIELDS = ('path', 'filename', 'source_url', 'has_local_file', 'priority', 'char_start', 'char_end', 'chunk_index', 'total_chunks') def build_attribution(source: dict) -> dict: """Только перечисленные поля, только по одному, явно. Здесь намеренно НЕ используется распаковка исходного словаря. Соблазн написать `{**source}` и удалить лишнее велик, но тогда каждое новое поле в реестре начнёт уходить наружу по умолчанию, и заметит это никто. """ return {f: (source.get(f) or None) for f in ATTRIBUTION_FIELDS} def build_response_item(fragment: Optional[str], source: dict, doc_id: str, budget_remaining: int, note: Optional[str] = None) -> dict: """Единственный путь, которым данные покидают сервис. Другого пути возврата текста не существует и не должно появиться: любой новый инструмент MCP обязан собирать ответ здесь же. """ item = { 'doc_id': doc_id, 'attribution': build_attribution(source), 'source_type': source.get('source_type'), 'budget_left': budget_remaining, } if fragment is not None: item['fragment'] = apply_ocr_notice(trim_fragment(fragment), source) if note: item['note'] = note return item def assert_no_forbidden_fields(payload) -> list: """Самопроверка сериализатора. Используется тестом приёмки и может вызываться в отладочном режиме на живых ответах. Возвращает список найденных запрещённых полей — пустой, если чисто. """ found = [] def walk(node, path=''): if isinstance(node, dict): for k, v in node.items(): if k in FORBIDDEN_FIELDS: found.append(f'{path}.{k}' if path else k) walk(v, f'{path}.{k}' if path else k) elif isinstance(node, list): for i, v in enumerate(node): walk(v, f'{path}[{i}]') walk(payload) return found