RAG library: file(s) -> mocked text recognition -> hierarchical on-disk index -> multi-tool search (BM25, FAISS vector, hybrid, TOC, grep, agentic)
Project description
raglib
RAG-библиотека: файлы → «распознавание текста» (в проде — целевая система, здесь — мок) → персистентный иерархический индекс → поиск несколькими инструментами. Инвариант выдачи: результат — всегда целый пункт документа с его номером, пригодный для программной обработки. План и архитектура — в PLAN.md.
Установка
pip install -e . # ядро: numpy, rank-bm25, faiss-cpu
pip install -e '.[stem]' # + snowballstemmer (BM25 stem — лучшее качество RU)
pip install -e '.[ru]' # + pymorphy3 (BM25 lemma)
pip install -e '.[gigachat]' # + langchain-gigachat (эмбеддинги + chat GigaChat)
pip install -e '.[langchain]' # + langchain-core (@tool-обёртки для интеграций)
pip install -e '.[dev]' # + pytest, ruff, build, все нормализаторы
LLM и эмбеддинги raglib не поставляет: вы передаёте готовые объекты
LangChain (llm и embeddings) напрямую — ставьте нужный провайдер сами
(langchain-gigachat для контура, langchain-openai и т.п.).
Extra комбинируются: pip install -e '.[stem,langchain]'. Ядро зависит
только от numpy, rank-bm25, faiss-cpu — всё остальное опционально.
RU-нормализация BM25 (bm25_normalizer="auto", дефолт) сама берёт лучший
доступный бэкенд: stem → lemma → none.
Сборка wheel (для переноса в закрытый контур)
Ядро — чистый Python, поэтому колесо получается универсальное
(py3-none-any). Для сборки нужен только setuptools>=68 (build-backend в
pyproject.toml) — отдельный пакет wheel НЕ требуется (современный
setuptools.build_meta сам умеет собирать .whl), build-фронтенд тоже
опционален.
На машине с интернетом — удобнее через build (сам разрешит зависимости):
python -m pip install build
python -m build --wheel # → dist/raglib-<версия>-py3-none-any.whl
«На месте», офлайн, в контуре — здесь есть нюанс: и python -m build, и
голый pip wheel по умолчанию создают ИЗОЛИРОВАННОЕ окружение для сборки и
пытаются СКАЧАТЬ setuptools из PyPI в него, даже если нужная версия уже
стоит в системе. Без сети это упадёт. Решение — флаг --no-build-isolation,
который заставляет использовать уже установленный в окружении setuptools
(в контуре это 80.9.0 — с запасом выше требуемых >=68):
pip wheel . --no-deps --no-build-isolation -w dist
Проверено: собирает колесо в venv, где кроме setuptools==80.9.0 ничего
нет (ни wheel, ни build, ни сети) — то есть эта команда работает именно
в условиях контура.
Проверка колеса в чистом окружении:
python -m venv /tmp/check && /tmp/check/bin/pip install dist/raglib-*.whl
/tmp/check/bin/python -c "import raglib, faiss; print(raglib.__version__)"
Установка в закрытом контуре (без интернета) — заранее скачайте зависимости там, где сеть есть, и перенесите вместе с колесом raglib:
# на машине с интернетом: собрать колёса всех зависимостей
pip wheel 'raglib[gigachat]' -w wheelhouse # или: pip download raglib -d wheelhouse
# в контуре: поставить только из локальной папки, без обращения к PyPI
pip install --no-index --find-links wheelhouse 'raglib[gigachat]'
Версия задаётся в pyproject.toml (project.version) — поднимите её перед
сборкой нового колеса. Артефакты (dist/, build/) в git не коммитятся
(см. .gitignore).
Деплой в закрытый контур (офлайн)
raglib совместим с окружением контура как есть — его зависимости уже стоят
там нужных версий (сверено с requirements.txt целевого сервиса):
| raglib | требует | в контуре | |
|---|---|---|---|
numpy |
≥1.24 | 2.3.3 | ✅ |
faiss-cpu |
≥1.7.4 | 1.12.0 | ✅ |
rank_bm25 |
≥0.2.2 | 0.2.2 | ✅ |
pymorphy3 (BM25 lemma) |
≥1.3 | 2.0.5 | ✅ |
langchain-gigachat (LLM+эмбеддинги) |
— | 0.5.0 | ✅ |
snowballstemmer (BM25 stem) |
≥2.2 | — | опц. |
Установка офлайн (raglib — из колеса, зависимости фиксированы под контур —
см. requirements-contour.txt):
pip install --no-index --find-links wheelhouse \
-c requirements-contour.txt 'raglib[gigachat,langchain]'
BM25-нормализация: дефолт bm25_normalizer="auto" в контуре сам выбирает
lemma (pymorphy3 есть, snowballstemmer нет) и пишет конкретный выбор в
manifest — менять requirements не нужно. Для лучшего качества (stem,
MRR 0.938 против 0.854) добавьте snowballstemmer (чистый Python, без
зависимостей) — auto подхватит его сам.
GigaChat через LangChain — и эмбеддер, и chat-модель передаются напрямую, без адаптеров:
from langchain_gigachat import GigaChatEmbeddings, GigaChat
from raglib import RagIndex
emb = GigaChatEmbeddings(credentials="...", scope="GIGACHAT_API_CORP", verify_ssl_certs=False)
index = RagIndex.build(inputs="docs/", index_dir="./idx", embeddings=emb)
llm = GigaChat(credentials="...", scope="GIGACHAT_API_CORP", verify_ssl_certs=False)
res = index.agentic_search("Какие сделки требуют одобрения совета?", llm=llm, top_k=8)
Проверено: в venv с точными пакетами контура (numpy 2.3.3, faiss, rank-bm25,
pymorphy3; без snowballstemmer) весь конвейер — сборка (auto→lemma) →
BM25 → навигация → перезагрузка — работает, все 65 офлайн-тестов зелёные.
Быстрый старт
from raglib import RagIndex
from raglib.recognition import MockRecognizer
from raglib.embeddings import HashingEmbeddings # офлайн; в проде — langchain_gigachat.GigaChatEmbeddings
# построить индекс (вход — .md, как отдаёт целевая система распознавания)
index = RagIndex.build(
inputs=["docs/charter.md"], # файл / список / директория
index_dir="./charter_index",
recognizer=MockRecognizer(), # шов для реальной системы распознавания
embeddings=HashingEmbeddings(), # None → BM25-only индекс
)
# загрузить готовый
index = RagIndex.load("./charter_index", embeddings=HashingEmbeddings())
# поиск: bm25 | vector | hybrid; strategy="tree" — сначала разделы, потом пункты
# BM25-нормализация RU: bm25_normalizer="auto" (дефолт) берёт лучший доступный
# бэкенд stem→lemma→none («сделки»≈«сделкой»); можно задать явно "stem"/"lemma"/"none".
# Конкретный выбор пишется в manifest; при load() переопределяется без пересборки.
hits = index.search("крупные сделки", method="hybrid", top_k=5)
for h in hits:
print(h.clause_number, h.doc_id, h.score) # "13.1", ...
print(h.text) # ПОЛНЫЙ текст пункта
# оглавление и разделы: заголовки обогащаются текстом («## 7.» → первое
# предложение тела раздела), артефакты распознавания в превью не попадают
print(index.toc()) # ключи + заголовки
print(index.toc(preview=True)) # + превью-предложение у каждого раздела
print(index.toc(clauses=True)) # + номера пунктов под каждым разделом
# (полнота сегментации видна сразу)
entries = index.toc_entries(doc="charter") # структурно: key/title/preview/level
print(index.read_section("charter", "12.1")) # раздел целиком, с подразделами
# find_section: по ключу, заголовку И превью содержимого; semantic=True — по смыслу
refs = index.find_section("наблюдательный совет")
refs = index.find_section("подтверждение решений собрания", semantic=True)
# навигационный поиск: раздел по смыслу → ranked-поиск внутри него
ref = refs[0]
hits = index.search("нотариальное удостоверение", method="bm25",
doc=ref.doc_id, section=ref.key)
# regex-поиск (выдача — те же целые пункты)
hits = index.grep(r"\d+\s*процент")
# агентский поиск: план → мульти-инструментальный поиск → LLM-рефлексия → дообыск
# llm — любая LangChain chat-модель (.invoke), передаётся напрямую
from langchain_gigachat import GigaChat
llm = GigaChat(credentials="...", scope="GIGACHAT_API_CORP", verify_ssl_certs=False)
res = index.agentic_search("Какие сделки требуют одобрения наблюдательного совета?",
llm=llm, top_k=8)
print(res.degraded, [h.clause_number for h in res.hits])
# удаление (валидирует, что папка — индекс raglib)
index.delete() # или RagIndex.delete_index("./charter_index")
Инструменты поиска
Все методы работают над одним индексом и возвращают SearchHit — целый
пункт с номером (кроме toc_*, отдающих структуру оглавления). Оглавление
(## СОДЕРЖАНИЕ / ОГЛАВЛЕНИЕ) в поиск не попадает: это дайджест-навигация,
который иначе матчился бы почти на любой запрос (он перечисляет все статьи) и
возвращался бы с пустым номером. Сам раздел остаётся в toc() / find_section.
| Метод | Что делает |
|---|---|
search(q, method="bm25") |
лексический BM25 (нормализация RU: stem/lemma/none) |
search(q, method="vector") |
векторный (FAISS, косинус); strategy="tree" — разделы→пункты |
search(q, method="hybrid") |
BM25 + вектор через RRF |
search(..., doc=…, section=…) |
фильтры: документ / префикс раздела по нумерации |
grep(pattern) |
regex по пунктам (выдача — те же целые пункты) |
toc(preview=…, clauses=…) |
оглавление; toc_entries() — структурно |
find_section(q, semantic=…) |
раздел по ключу/заголовку/превью или по смыслу |
read_section(doc, key) |
раздел целиком, с подразделами, без усечения |
agentic_search(q, llm=…) |
промт → план → поиск → LLM-рефлексия → дообыск |
Формат выдачи — SearchHit:
h.clause_number # "13.1" — номер пункта ("" у ненумерованных)
h.locator # СТАБИЛЬНАЯ ссылка, НИКОГДА не пустая: номер пункта, а если
# номера нет — путь-провенанс ("Отзыв доступа") или id ("notes.md#4")
h.text # полный текст пункта — точный срез исходного markdown, не режется
h.score # ранг метода (BM25 / косинус / RRF / число совпадений grep)
h.doc_id # идентификатор документа (санитизированное имя файла)
h.doc_name # имя исходного документа ("charter.md"); "" → см. doc_id
h.section_path # цепочка предков ПО КЛЮЧАМ: ["12", "12.1", "12.1.4"] (у раздела
# без номера — синтетический ключ ["§3"])
h.section_titles # заголовки этих разделов, выровнены с section_path 1:1
# ["Статья 12. …", "12.1. …", ""] ("" у листа-пункта)
h.path # читаемый путь глава→пункт: заголовок (или номер) на каждом уровне
h.breadcrumb # тот же путь строкой: "Статья 12. … › 12.1. … › 12.1.4"
h.method # bm25 | vector | hybrid | grep | agentic
h.verdict # relevant | partial — только для agentic_search
Пункты и разделы без номера. Не у всех документов есть нумерация (регламенты,
преамбулы, приложения). raglib работает с ними как с полноценными: каждому
разделу без номера присваивается стабильный синтетический ключ §1, §2, …
— он показывается в toc() как [§N], принимается в read_section/find_section
и в фильтре section=. У пункта без номера clause_number остаётся пустым (у него
и правда нет номера), но breadcrumb даёт раздел-владелец, а locator — всегда
непустую ссылку для программной привязки. Оглавление (## СОДЕРЖАНИЕ) при этом
из поиска исключено (см. «Инструменты поиска»). Демонстрация — в
notebooks/demo.ipynb.
agentic_search возвращает AgenticResult: .hits, .trace (журнал шагов),
.degraded (True = откат в hybrid), .iterations, .llm_calls.
Контракт выдачи
Единица выдачи ретривера — всегда SearchHit (двухуровневая модель, PLAN.md
§1/§5: Chunk — единица ИНДЕКСАЦИИ, наружу не выходит; Clause/пункт — единица
ВЫДАЧИ). search() и grep() возвращают list[SearchHit]; agentic_search() —
AgenticResult, у которого .hits — тот же list[SearchHit].
Гарантии для каждого хита:
- Целый пункт, не фрагмент. Совпадение ищется по внутренним окнам, но результат всегда агрегируется до целого нумерованного пункта.
- Точная привязка к источнику.
h.text == doc.md_text[span[0]:span[1]]— дословный, неусечённый срез распознанного markdown (h.text in doc.md_textвсегда истинно); переразмерный пункт возвращается целиком, а не по окну совпадения. - Без дублей. Один
clause_idне повторяется; score пункта — максимум по его окнам. - Согласованность номера. Если
clause_numberнепустой —section_path[-1] == clause_number. - Провенанс-метаданные.
doc_name+ путь глава→пункт:section_titlesвыровнены сsection_path1:1 (""там, где у уровня нет заголовка, напр. у листа-пункта),breadcrumb == " › ".join(path).
Инварианты проверяются для ВСЕХ методов (tests/test_search.py::_assert_output_invariants).
Порядок и фильтры. Хиты — по убыванию релевантности (BM25 / косинус / RRF / число
совпадений grep), не более top_k. doc= / section= сужают кандидатов, но не меняют
ни одной гарантии выше.
Деградация — явная, не молчаливая.
method="vector"|"hybrid"поднимаютRuntimeError, если индекс BM25-only или на поиск не передан клиент эмбеддингов.bm25,grep,tocработают полностью офлайн.agentic_searchвыставляетres.degraded=Trueпри откате в hybrid (и всё равно возвращает пункты, соблюдающие инвариант).
Вне контракта. Внутренние Chunk наружу не отдаются. score НЕ сравним между
методами и между запросами. toc() / find_section() возвращают TocEntry / SectionRef
(структуру навигации), а НЕ SearchHit — это другой контракт.
Одной строкой: каждый результат — целый, дословный, однозначно идентифицированный нумерованный пункт со своим документом и путём глава→пункт, упорядоченный по релевантности.
Чанкинг (индексация)
Индексируется не пункт целиком, а его внутренние окна — и только у переразмерных
пунктов. Двухуровневая модель Clause/Chunk (PLAN.md §5):
- Сначала документ режется на целые пункты (
segment_clauses) — по нумерации, не по длине. Оглавление сюда не попадает; разделы без номера — обычные пункты. - Затем по каждому пункту строятся окна (
window_spans, счёт по символам):- пункт ≤
chunk_size→ один чанк = весь пункт (1:1, обычный случай); - пункт >
chunk_size→ скользящие окна ширинойchunk_sizeс перекрытиемchunk_overlap(шаг =chunk_size − chunk_overlap).
- пункт ≤
BM25 и FAISS строятся по чанкам; на поиске скор чанков агрегируется обратно в
пункт (score = максимум по чанкам пункта, дедуп по clause_id), поэтому в выдачу
всегда уходит целый пункт, а не окно. Векторы разделов — mean-pooling векторов их
чанков. Чанки наружу не отдаются никогда.
Параметры: RagIndex.build(chunk_size=1500, chunk_overlap=150) (символы) — пишутся в
manifest chunking: {size, overlap}.
Что видит LLM в агентском поиске
Не путать с чанкингом. В agentic_search чанки в LLM не попадают — кандидаты уже
собраны в целые пункты. Но на шаге REFLECT текст каждого пункта обрезается до
snippet_chars (по умолчанию 800 символов) — только для промпта оценки
релевантности. Пункт, разбитый на ≥2 чанка, по определению длиннее chunk_size
(>1500 символов), поэтому LLM в reflection видит только первые ~800 символов, а не
пункт целиком. При этом в res.hits[i].text пункт возвращается целиком, дословно:
- LLM оценивает релевантность по первым
snippet_charsсимволам пункта; - в выдачу пункт уходит целым (инвариант «целый пункт» соблюдён).
Следствие: если ключевая информация в хвосте длинного пункта (после snippet_chars),
вердикт LLM строится на его начале — ретрив всё равно находит пункт по хвостовому чанку,
а выдача остаётся целой. snippet_chars зафиксирован на 800 в фасаде agentic_search();
чтобы увеличить, создайте AgenticSearcher(engine, llm, snippet_chars=N) напрямую.
Устойчивость к OCR-ошибкам в номерах
Распознавание сканов путает цифры с похожими буквами и точку с запятой, поэтому
пункт «10.2» приходит как «1О.2», «l0.2» или «10,2», и строгий сегментатор его бы
пропустил. При ocr_number_repair=True (дефолт в RagIndex.build) номера
восстанавливаются и в нумерации пунктов, и в ключах разделов:
| Ошибка OCR | Пример | → |
|---|---|---|
буква вместо 0 |
1О.2 / 1O.2 (кир./лат. O) |
10.2 |
l / I вместо 1 |
l2.3 |
12.3 |
З вместо 3, б вместо 6, S вместо 5 |
1б.4 |
16.4 |
| запятая вместо точки | 12,1 |
12.1 |
| лишние пробелы | 7 . 2 |
7.2 |
Так search, toc, find_section и фильтр section= работают по
восстановленному номеру: find_section("10") найдёт раздел «Статья 1О», а хит
получит locator="10.2". Восстановленный текст пункта в выдаче не меняется —
h.text остаётся дословным срезом исходника (правится только номер-ключ).
Защита от ложных срабатываний (parsing/ocr.py):
каждая часть номера обязана содержать хотя бы одну настоящую цифру, поэтому
слово («Общие», «Зона») или римская цифра («II») никогда не станут номером; а
результат с ведущим нулём отбрасывается как дата/сумма (01.02.2025 — не пункт).
Полностью строгий разбор — RagIndex.build(..., ocr_number_repair=False)
(ключи разделов из заголовков всё равно восстанавливаются — они надёжнее).
LLM и эмбеддинги — объекты LangChain напрямую
raglib не поставляет клиентов провайдеров и не оборачивает их в адаптеры: вы
строите объекты LangChain в своём коде и передаёте их как есть. Тот же llm и
embeddings, что уже крутятся в вашем LangChain / deepagents стеке, работают и
здесь.
LLM для агентского поиска — любой LangChain chat-модели достаточно
(.invoke(messages) → AIMessage): raglib шлёт OpenAI-совместимые
{"role","content"}-словари прямо в .invoke() и разворачивает ответ (строку
или список content-блоков) в текст сам.
from langchain_gigachat import GigaChat # контур
# from langchain_openai import ChatOpenAI # или любой другой провайдер
llm = GigaChat(credentials="...", scope="GIGACHAT_API_CORP", verify_ssl_certs=False)
res = index.agentic_search("…", llm=llm, top_k=8) # llm=None → без рефлексии, plain hybrid
Эмбеддинги — тоже напрямую: протокол raglib (embed_documents /
embed_query) совпадает с интерфейсом LangChain-эмбеддеров.
from langchain_gigachat import GigaChatEmbeddings # прод в контуре
RagIndex.build(inputs=..., index_dir=..., embeddings=GigaChatEmbeddings(...))
Эмбеддинги
Что передать в embeddings= |
Когда |
|---|---|
| любой LangChain-эмбеддер напрямую | прод: langchain_gigachat.GigaChatEmbeddings в контуре и т.п. |
HashingEmbeddings() |
тесты/CI: детерминированный, без сети |
None |
BM25-only индекс (vector/hybrid дают понятную ошибку) |
Результаты тестирования
Офлайн-набор: 73 unit/integration-теста (фикстуры, HashingEmbeddings,
MockLLM — сеть в CI не нужна): парсинг разделов и пунктов, OCR-ошибки в
номерах, разделы без номера (§N) и исключение оглавления, все артефакты
распознавания, roundtrip хранилища, все методы поиска, инварианты выдачи,
навигация, RU-нормализация (в т.ч. авто-выбор бэкенда), агентский цикл
(happy-path / refine / деградация / бюджеты). Отдельно проверен запуск в
venv, имитирующем контур (без snowballstemmer).
Боевой корпус: два распознанных устава (ООО, 18 стр. + АО, 35 стр.,
markdown из docling), эмбеддинги google/gemini-embedding-001
через OpenRouter (dim 3072). Итог: 447 пунктов / 494 юнита, сборка ~30 с.
Полнота сегментации (после обработки артефактов распознавания: пункты-списки
- 1.1 …, пробелы в номерах 7 . 2., склейки 7.3.1.текст, пункты-строки
таблиц, таблицы, сплющенные в одну строку, OCR-склейки 9. 21.2.):
ноль дыр в нумерации и ноль дубликатов номеров на обоих уставах;
самый длинный пункт сжался с 19,6 тыс. до 3 тыс. символов.
Проверка своего корпуса: index.toc(clauses=True) — дыры видны сразу.
Качество поиска (8 перефразированных юридических запросов, релевантность — автоматически по паттернам в тексте пункта):
| Конфигурация | MRR | Σ релевантных@5 |
|---|---|---|
| bm25, normalizer="none" | 0.719 | 24 |
| bm25, normalizer="stem" (дефолт) | 0.938 | 34 |
| bm25, normalizer="lemma" | 0.854 | 30 |
| hybrid (stem) | 0.854 | 32 |
| vector (gemini-embedding-001) | 0.833 | 27 |
Контрольный случай: пункт о неприменении ст. 45 (сделки с заинтересованностью) по перефразированному запросу — вне топ-10 без нормализации → ранг 1 со stem.
Агентский поиск вживую на слабой модели (deepseek/deepseek-v4-flash,
та же, что в целевом контуре): 3 вопроса по уставам — все degraded=False,
по 1 итерации / 2 LLM-вызова / 25–35 с; PLAN и REFLECT стабильно возвращают
парсибельный JSON; выдача — целые пункты с вердиктами relevant/partial.
Как выбирать инструмент (рекомендации по итогам замеров)
| Задача | Инструмент |
|---|---|
| Точные термины, номера статей, проценты | search(method="bm25") или grep() |
| Перефразированный смысловой вопрос | search(method="vector") или "hybrid" |
| «Найди раздел про X и прочитай целиком» | find_section(semantic=True) → read_section() |
| Сложный вопрос без готовой формулировки | agentic_search() (фильтрует шум рефлексией) |
| Большой корпус / известна область | strategy="tree" и/или фильтры doc=, section= |
Практические советы:
- Нормализатор BM25:
stem(дефолт) — лучший по замеру;lemmaне окупает зависимость pymorphy3;none— только если нужны точные словоформы. Менять режим можно приload(bm25_normalizer=...)— пересборка и повторные эмбеддинги не нужны. - BM25-only режим (
embeddings=None) — законный: BM25 + TOC + grep работают полностью офлайн; это же деградация при недоступности эмбеддера. find_sectionпо подстроке — для случаев «примерно знаю название раздела»; одиночные частотные слова («протокол») цепляют преамбулы. Для смысла —semantic=True.- Агентский поиск: проверяйте
res.degraded(True = честный откат в hybrid) и держитеres.traceв логах — там весь план/вердикты для разбора качества. A/B против обычного поиска: тот же вызов сllm=None. - Выдача любого метода — целые пункты с номерами (
clause_number, полныйtext— точный срез распознанного markdown): можно парсить программно и цитировать без сверки с оригиналом.
Тесты
python -m pytest # полностью офлайн: HashingEmbeddings + MockLLM + фикстуры
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agentic_rag_lib-0.1.0.tar.gz.
File metadata
- Download URL: agentic_rag_lib-0.1.0.tar.gz
- Upload date:
- Size: 82.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bb412748da614432ed6004bf7053a920e5e3e78892a46a9fa110d3a071b311b2
|
|
| MD5 |
5e1913a77c06c164806d043d4d65305b
|
|
| BLAKE2b-256 |
07afd3bd11df34acf94fa5d4497cf9c2bdc776bd196bf5988a1bf9ef663ad398
|
File details
Details for the file agentic_rag_lib-0.1.0-py3-none-any.whl.
File metadata
- Download URL: agentic_rag_lib-0.1.0-py3-none-any.whl
- Upload date:
- Size: 56.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
05f9817d67b20564ce7604c062d2273d624de739fd540d8db5e0c8936748dd7d
|
|
| MD5 |
86ce1c025578f3c2dbfff28f47af1ac7
|
|
| BLAKE2b-256 |
9db0ea24180d3154840ad3af8d8fd984a9b307ed7a4c1fe56a05d0eced848a52
|