← Knowledge Store

Тесты

knowledge-store · workzone
Принятые решения
Одна база — тестируем одним SQL, не моками проекций Несущая опора дизайна — тело, вектора и граф в одном Postgres, права на обходе одним JOIN. Поэтому ядро покрытия — интеграция против настоящего Postgres с pgvector: ACL pre-filter, обход графа и upsert проверяются на живой СУБД, а не на подменённом хранилище. Моками закрываем только чистую логику без БД (chunking, fusion, decay).
Тип задаёт уровень, приоритет — маркеры Unit — детерминированная логика без СУБД, на каждом PR. Integration и API — против тестового Postgres+pgvector в Docker, отдельным шагом. P0 держит несущие инварианты (ACL не утекает, upsert идемпотентен), P1 — доводку и режимы; приоритет ортогонален типу (pytest -m p0).
Граница покрытия совпадает с границей модуля KS отвечает за retrieval и хранение: примитивы, fusion, ACL-фильтр, lifecycle. RAG поверх (rerank, упаковка, вызов LLM) — за Query Engine, здесь не тестируется. Захват прав и контракт Entity — за Harvester; на стыке проверяем лишь, что пришедшее корректно персистится и читается под правами.
Стек и инфраструктура
Имеется done pytest, pytest-asyncio, pytest-cov, httpx
База testcontainers — тестовый Postgres с расширением pgvector (вектора + recursive SQL в одной СУБД); миграции Alembic накатываются на старте контейнера
Фабрики factory_boy для Entity / chunks / entity_edge и пятёрки ACL — сборка графа и прав сцены без прохода через Harvester
Эмбеддинг фейковая модель эмбеддингов (детерминированный вектор по тексту) — чтобы поиск по близости был воспроизводим, без реального инференса в авто-прогоне
Прочее time-machine (давность для staleness decay), шаги Curation Pass вызовом напрямую — без поднятия планировщика
Маркеры @pytest.mark.unit / @pytest.mark.integration / @pytest.mark.api — по типу; @pytest.mark.p0 / p1 — по приоритету, ортогонально типу
Unit Чистая логика без СУБД детерминированно · каждый PR
test_chunking.py
Резка тела на фрагменты, ordinal, контент-хеш
Unit → chunks
Кейсы
тело → фрагментыдлинный body режется на куски; короткий — один фрагмент; пустой — ни одного
ordinal по порядкуфрагменты нумеруются подряд от начала текста — UNIQUE (entity_id, ordinal) держится
content_hash на фрагментхеш считается по тексту куска; одинаковый текст → одинаковый хеш
переэмбеддинг только при смене текстаправка одного фрагмента → его хеш меняется, у соседних прежний; переэмбеддится только изменённый
token_countдлина куска в токенах проставляется; режим уважает бюджет токенов на фрагмент
test_fusion.py
Слияние четырёх ранжированных списков в один
Кейсы
четыре списка → одинvector / lexical / graph / sql сливаются в единый ранжированный результат на сконструированных списках
общий ключ — сущностьфрагментные хиты (vector / lexical) сворачиваются к родителю по entity_id, лучший фрагмент остаётся при сущности; одна сущность из нескольких списков дедуплицируется
RRF по обратному рангувес позиции, а не сырого балла (несравнимого у разных парадигм); общий для нескольких списков кандидат поднимается
взвешенное слияниевеса примитивов сдвигают итоговый порядок предсказуемо
детерминированностьте же входы → тот же выход бит-в-бит; тай-брейк стабилен
частичные входыодин примитив пуст / дал меньше — слияние не падает, остальные учитываются
test_emptiness.py
Производное свойство is_empty — есть ли что искать
Кейсы
нет фрагментов → is_emptyв chunks ни строки → is_empty истинно; источник истины — наличие фрагментов для поиска, не число источников или сущностей
первый фрагмент → переключениепринят первый chunkis_empty ложно; свойство меняется само, без отдельной команды или флага
эмбеддер назначен, фрагментов нет → всё ещё пустоназначенный harvester_embedding при нулевых chunks базу непустой не делает — пустота определяется фрагментами, не готовностью приёма
производное, не хранимый флаготдельной колонки-состояния нет; is_empty вычисляется из наличия chunks (реализация вправе кэшировать), вручную не выставляется — как is_available() у SMTP
test_staleness_decay.py
Decay-функция занижения trust_score
Кейсы
авторитет — базапри равных давности и спросе источник с более высоким authority_tier (high > normal > low) даёт больший trust_score; уровень читается JOIN по source_id
давность → нижедавний source_updated_at даёт меньший trust_score, свежий — выше (time-machine)
частота обращенийневостребованное опускается; запрашиваемое держит вес; спрос читается JOIN из access_counter (hits · last_accessed_at), давно не запрашиваемое затухает к нейтрали
монотонностьфункция не растёт со старением; без скачков и отрицательных значений
только вес, не доступdecay меняет trust_score, не трогает status / is_deleted — устаревшее остаётся видимым
test_traversal_builder.py
Построитель recursive-CTE обхода графа
Кейсы
глубина 1–3собранный recursive-CTE ограничивает обход заданной глубиной шагов
защита от цикловцикл в рёбрах не зацикливает обход — посещённые узлы не повторяются
направление и rel_typeобход по src→dst с фильтром по виду связи строится корректно
слот под ACL JOINшаблон оставляет место для JOIN entity_acl на посещённых id — права лягут в тот же запрос
Integration · P0 Несущие инварианты против Postgres + pgvector testcontainers · отдельный шаг CI
test_acl_prefilter.py
ACL pre-filter одним SQL — неразрешённое не доходит
Кейсы
vector под правамипоиск по близости над chunks возвращает только фрагменты разрешённых сущностей — отбор тем же SQL, до ранжирования
lexical под правамиполнотекстовый поиск (text_tsv + GIN) фильтруется тем же ACL JOIN — точное слово из неразрешённой сущности не утекает
фрагмент наследует ACLchunk своего ACL не имеет; доступ резолвится через entity_identity_acl
грант группе vs прямойвидно через членство (group_membership) ИЛИ прямой грант (entity_acl.source_principal_id); цепочка резолва от users
public — wildcard на строкегрант scope='public' (оба principal/group NULL) виден всем — и пользователю без членств, и анониму по правилам источника; в фильтр уходит как scope='public', без синтетической группы
scope ↔ поля согласованыCHECK scope IN (group/principal/public) плюс согласованность: group → заполнен source_group_id, principalsource_principal_id, public → оба NULL; кривая комбинация отвергается БД
отозванное не утекаетубрали из группы / сняли грант → сущность исчезает из выдачи без отдельного post-фильтра
пустой доступпользователь без личностей / членств → пустая выдача, не ошибка и не утечка
test_graph_traversal.py
Обход entity_edge под тем же ACL JOIN
IntegrationP0 → entity_edge
Кейсы
обход 1–3 шагаrecursive SQL от стартовых узлов приносит ближний контекст: тред, иерархию, упоминания
единый ACL на обходеобход покрыт тем же JOIN entity_acl — узлы без доступа не попадают в результат, без отдельной проекции прав
недоступный узел рвёт путьпуть через неразрешённую сущность не «перепрыгивает» её и не утекает дальше
цикл в графереальный цикл рёбер на живой БД не зацикливает CTE
fanout-cap на шагхаб-узел с тысячами рёбер: cap на fanout ограничивает число потомков, разворачиваемых с одного узла за шаг — обход не взрывается в десятки тысяч рёбер; отсечка срабатывает до ACL JOIN, а не после
отсев по weightрёбра ниже порога weight отбрасываются из обхода — слабые связи не тянутся в контекст; фильтр применяется до ACL, наравне с fanout-cap сдерживая раздувание с хаб-узла
test_entity_upsert.py
Идемпотентность upsert по ключу источника
IntegrationP0 → entities
Кейсы
один ключ — одна строкапо UNIQUE (source_id, source_type, source_entity_id) повторный upsert обновляет, не плодит дубли
проекции в порядкетело → chunksentity_edge в одной транзакции; FK держатся
смена content_hashправка body → переэмбеддинг затронутых фрагментов; неизменные не переэмбеддятся
синхронизация набора фрагментовукоротили текст → лишние chunks удалены, осиротевших нет (CASCADE по entity_id)
автор → SET NULLудаление source_principal обнуляет entities.author_principal_id (ON DELETE SET NULL) — сущность жива; удаление source уносит всё содержимое по CASCADE
status — только из словарянепустое значение status вне (draft/final/archived) отвергается CHECK; NULL допустим — до шага classify
test_soft_delete.py
is_deleted прячет из выдачи, не удаляет физически
IntegrationP0 → entities
Кейсы
скрыта из выдачиis_deleted = true → запись и её фрагменты выпадают из всех четырёх примитивов, строка физически жива
фрагменты несут зеркало флагаденормализованный chunks.is_deleted ставится в той же транзакции; частичные HNSW/GIN (WHERE NOT is_deleted) исключают удалённое из обхода индекса
отдельная ось от statussoft-delete не трогает status (draft/final/archived) — оси независимы
восстановлениеснятие маркера возвращает запись в выдачу с прежним статусом
deleted_at ставит reconciliationмомент исчезновения проставляется при пропаже из источника, не в инкременте
test_identity_bridge.py
Авто-связь identity↔users по точному email
IntegrationP0 → identity ↔ users
Кейсы
связь с обеих сторонAuth заводит users → KS ставит identity.user_id по точному lower(email); Harvester апсёртит identity → KS досвязывает по users; обе точки держит KS
нормализация регистраALICE@CORPalice@corp связываются — свод по UNIQUE (lower(email)), коллизий регистра нет
мост 1:1partial unique (user_id) WHERE user_id IS NOT NULL — одна identity на users; повторная связь идемпотентна, второй привязки не плодит
нет совпадения → NULLemail не нашёлся (другой адрес / отсутствует) → user_id = NULL, личность остаётся unmatched под ручную привязку в Admin, не ошибка
связь синхроннамост ставится в момент создания/апсёрта, не ждёт ближайшей sync-волны — на первый же запрос доступ резолвится через user_id
Integration · P1 Curation Pass, refresh, журнал, recall, backup testcontainers · отдельный шаг CI
test_curation_pass.py
Шаги доводки графа по уже собранной базе
IntegrationP1 → Curation Pass
Кейсы
cross-source реброупоминание материализуется в entity_edge с origin = curation, когда оба узла уже в графе; заявка entity_ref до того, после материализации строка удаляется
forward-ссылка внутри источникаотложенная ссылка на ещё не загруженную цель того же источника ждёт заявкой в entity_ref; прогон материализует ребро (origin = curation) после того, как цель приходит следующим инкрементом — заявка снимается
цель-user резолвится в телозаявка с target_kind = user (напр. author → личность) гасится записью author_principal_id в тело сущности; entity_edge при этом не создаётся — не всякая заявка становится ребром
мёрж ноддве сущности, признанные одним объектом, схлопываются: рёбра переносятся, дубль помечен duplicate_of
нечёткое сведение личностейнесведённые source_principal под разными email сопоставляются, identity_id проставляется; мост к users досвязывается по разным email
decay пишет trust_scoreшаг staleness заносит занижённый entities.trust_score в БД
retention v2по политике: архивация прячет из горячей выдачи, удаление вычищает физически вместе с chunks по CASCADE
шаги идемпотентныповторный прогон не вредит; частичный сбой одного шага не рушит остальные
несведённое — на разборчто нечётко связать не удалось, остаётся unmatched (identity_id = NULL) для ручного разбора, не выбрасывается
test_embedding_refresh.py
Переэмбеддинг по событию смены модели
IntegrationP1 → Embedding refresh
Кейсы
смена модели → перегенерациярассинхрон chunks.embedding_model с текущей моделью → массовый прогон пересчитывает chunks.embedding
обновление embedding_modelпосле прогона embedding_model у фрагментов = текущая модель платформы
неизменная модель — пустомодель не менялась → прогон ничего не пересчитывает (индекс уже консистентен)
вне цепочки расписаниятриггерит событие, не таймер; обычная загрузка эмбеддит лишь изменённый текст по content_hash
смена размерности — не этот путьэтот прогон покрывает смену модели при той же размерности N (построчный пересчёт); смена самой N (halfvec(N)) — миграция (новая колонка → CREATE INDEX CONCURRENTLY → атомарный своп), не in-place refresh, и сюда не входит
test_curation_runs.py
Control-plane: видимость прогона
IntegrationP1 → curation_runs
Кейсы
строка на прогонстарт пишет running + started_at; финал — succeeded / failed + finished_at
статистика в stepsкакие шаги отработали и с какими значениями — гибким JSONB: рёбер материализовано, дублей сведено, сущностей занижено
провал в errorсбой → state = failed + краткая причина в error; уже прогнанные шаги отражены в steps
CHECK на stateзначение вне (queued/running/succeeded/failed/cancelled) отвергается БД
один активный прогонвторой незавершённый прогон отвергается singleton partial unique index — на платформе максимум один queued/running
отмена → cancelledотмена running-прогона (UI «Отменить») терминализует его в cancelled + finished_at, освобождает singleton-замок; следующий прогон стартует свежим
CHECK на triggerзначение вне (schedule/model_change/manual) отвергается БД; событийный переэмбеддинг пишется строкой trigger = model_change
test_coordination.py
Взаимное исключение полос: доставка ∥ доводка
Кейсы
разрушительный шаг → sync в очередьпока мёрж/retention держит замок, прогон затронутого источника встаёт в queued, не падает, и стартует по завершении шага
добавочное идёт параллельноматериализация рёбер / decay / refresh не блокируют upsert Harvester — конкурентная запись переживается, рёбра не осиротевают (ON CONFLICT DO NOTHING)
мёрж под конкуренциейupsert слитой сущности во время мёржа не оставляет осиротевших рёбер — досводятся следующим прогоном, duplicate_of консистентен
протухший heartbeat отпускает замокдержатель умер, не закрыв прогон → по протуханию heartbeat_at замок отбирается, новый прогон стартует; fencing отсекает запоздалую запись
refresh без замка полосыпри активном переэмбеддинге новые chunks пишутся текущей моделью; смешанные поколения embedding_model сходятся, приём не встаёт
test_hnsw_recall.py
Недобор HNSW: is_deleted снимает частичный индекс, ACL-JOIN — на ручной замер
IntegrationP1 → chunks
Кейсы
is_deleted — вне обходаденормализованный chunks.is_deleted + частичный HNSW (WHERE NOT is_deleted) держит удалённое вне индекса — этот предикат недобора не даёт, он не post-фильтр
ACL-JOIN недобираетжёсткий ACL приходит JOIN-ом на entities → top-K приближённого HNSW частью отсекается уже после скана: разрешённых кандидатов меньше запрошенного, без ошибки
iterative_scan ACL не лечитhnsw.iterative_scan (pgvector 0.8.0+) добирает только по условиям на самой chunks; предикат через JOIN на entities им не компенсируется — потому ACL-recall не закрывается автоматически
метрика — косинусна сконструированных нормализованных векторах порядок близости идёт по косинусу (класс операторов vector_cosine_ops), не по L2-дефолту pgvector — метрика зафиксирована как контракт, а не унаследована случайно от индекса
точное значение — вручнуютест ловит сам факт недобора под ACL и границу лечения; абсолютный recall под реальные данные — за ручным замером ниже
test_backup_restore.py
Цикл резервного копирования и восстановления
IntegrationP1 → Backup & restore
Кейсы
снимок целостенодна база → тело, вектора, граф и права в бэкапе одной согласованной точкой
restore возвращает всёпосле восстановления три проекции и ACL совпадают с исходными; поиск и обход работают
вектора переживают циклchunks.embedding и HNSW-индекс восстанавливаются — близость считается как до бэкапа
журнал снимкаснятие пишет строку backup_snapshots: running + started_atsucceeded + finished_at + size_bytes + location; CHECK на state отвергает чужое значение
ротация по retention_countснимков сверх backup_settings.retention_count → самый старый удаляется и из журнала, и из хранилища
зависший снимок не запирает расписаниеворкер умер, оставив снимок в running → по протуханию heartbeat_at (~90с) сторож жнёт его в failed, замок single-flight отпускается, следующий по расписанию стартует
один активный снимокpartial unique WHERE state = 'running': второй бэкап поверх незакрытого отклоняется, параллельного дампа нет
креды write-onlydestination_creds_enc хранится зашифрованным, в API-ответе и data export не возвращается (как api_key_enc)
настройки — singletonbackup_settings CHECK (id = 1): вторая строка отвергается; приложение читает/обновляет, не создаёт
API · P1 Retrieval-эндпоинты — HTTP-контракт httpx · ASGI-приложение · Postgres+pgvector · → HTTP API → Conformance
test_api_retrieval.py
Четыре примитива по отдельности и собранный hybrid
Кейсы
примитивы как toolsvector / lexical / graph / sql вызываются по отдельности → ранжированный список своей парадигмы
собранный hybridодин вызов → fused-результат (fusion внутри KS), готовый под RAG-retrieval
форма запроса и валидациякривой параметр (глубина обхода вне 1–3, неизвестный фильтр) → 422, не 500
RAG здесь не происходитэндпоинт отдаёт кандидатов; rerank / упаковка / вызов LLM — за Query Engine, наружу не выставлены
предел top-Kпримитив возвращает не более K попаданий; K ограничен серверным потолком — запрос сверх потолка усечён до него, не безлимит
форма ответа retrievalсписок попаданий с score и id chunk/entity, порядок по убыванию релевантности; пустой результат → 200 с пустым списком, не 404
test_api_access.py
Выдача всегда ACL-фильтрована
Кейсы
фильтр по вызывающемуretrieval всегда сужен правами вызывающего; два пользователя на тот же запрос видят разное
неразрешённое не утекаетнедоступная сущность не приходит ни в одном примитиве, ни в fused, ни в обходе графа
анонимбез токена → 401 (параметризовано по всем retrieval-роутам)
нет «голых» роутовкаждый retrieval-эндпоинт реально за guard и ACL-фильтром — параметризованный аудит покрытия
API · P1 KS-admin — HTTP-контракт прогонов httpx · ASGI-приложение · → HTTP API → Conformance
test_api_admin_ops.py
Запуск обслуживания и статус источников по HTTP
Кейсы
запуск прогона → 202reindex / re-embed / backup «сейчас» → 202 + id прогона; строка curation_runs / backup_snapshots в queued, задача в SAQ
статус источниковGET → срез по источникам: состояние, счётчики сущностей/фрагментов, отметка последнего прогона
restore из снапшотавосстановление из backup_snapshots202; неизвестный снапшот → 404
замок разрушительныхмёрж/retention под замком целевых полос — конкурентный запуск → 409, не порча данных
повторный запуск под замком → 409второй POST reindex / re-embed / backup, пока прогон уже queued/running409 CONFLICT (single-flight по curation_runs / backup_snapshots) — не второй прогон и не 500
валидация как 422кривой параметр прогона / расписания → 422, не 500
test_api_admin_access.py
Прогоны обслуживания — только под ролью
APIP1
Кейсы
только Owner/AdminOwner/Admin запускает прогон / читает статус → 200/202; Member → 403
анонимбез токена → 401 (параметризовано по всем admin-роутам KS)
нет «голых» роутовкаждый admin-эндпоинт реально за guard роли — параметризованный аудит покрытия
Manual Замер recall на реальных данных — вне CI ручной замер · не замкнуть в авто-тест

Абсолютный recall приближённого индекса HNSW под жёстким ACL-фильтром зависит от распределения реальных данных и формы прав — общим оценкам из бенчмарков доверять нельзя, а синтетика его не воспроизводит. Авто-тест ловит лишь факт недобора и границу лечения: hnsw.iterative_scan добирает по условиям на самой chunks, но ACL приходит JOIN-ом на entities и итеративным сканом не компенсируется — потому само значение, что ACL-фильтр не роняет полноту ниже приемлемого, снимается вручную на представительном корпусе. Назначение — поднять recall под фактическую нагрузку параметрами индекса (m, ef_search), раз iterative_scan JOIN-предикат не закрывает, — не в авто-прогоне.

Structure Файловая структура тестов

Разделение по типу: unit/ — чистая логика без СУБД, идёт на каждом PR. integration/ и api/ — против тестового Postgres с pgvector в Docker, отдельным, более редким шагом. Приоритет (P0–P1) ортогонален каталогам и задаётся маркерами (pytest -m p0). Живой замер recall остаётся ручным инструментом и в авто-прогон не попадает.

  • tests/knowledge_store/каталог модуля
    • conftest.pytestcontainers Postgres+pgvector · фабрики Entity/chunks/edge + пятёрки ACL · фейковая модель эмбеддингов · шаги Curation Pass напрямую
    • unit/чистая логика без СУБД
      • test_chunking · test_fusion · test_staleness_decay · test_traversal_builderрезка, слияние, decay, построитель обхода
    • integration/против Postgres+pgvector
      • test_acl_prefilter · test_identity_bridge · test_graph_traversal · test_entity_upsert · test_soft_deleteP0 — несущие инварианты: ACL, мост личностей, обход, upsert, soft-delete
      • test_curation_pass · test_embedding_refresh · test_curation_runs · test_coordination · test_hnsw_recall · test_backup_restoreP1 — доводка, refresh, журнал, координация полос, recall, backup
    • api/HTTP-контракт — httpx + ASGI + Postgres+pgvector
      • test_api_retrieval.pyпримитивы как tools + собранный hybrid
      • test_api_access.pyвыдача всегда ACL-фильтрована
    • manual/вне CI — живой замер recall на реальных данных