Перейти к основному содержимому

ADR 0198 — О семантическом дубликате истории и правила предупреждает embedding с порогом, а расход попадает в metering

Статус​

Accepted. 2026-10-01 (AT-171, история HS-047, эпик EP-026, раунд 36; решение владельца от 01.10: «embedding с порогом, только предупреждает» — сравнивает при испускании с существующими историями и правилами проекта и ПРЕДУПРЕЖДАЕТ выше порога, никогда не блокирует; расход embedding этой проверки попадает в metering отдельной строкой). Открывает ОДНО исключение из отсечения metering ADR 0075, не редактируя его: отсечение продолжает действовать для индексации и поиска RAG (ADR 0080); только эта проверка начинает записывать token_usage. Закрывает половину, которую RN-080 и RN-081 объявляли недостижимой, не меняя ни одну из них: ТОЧНЫЙ дубликат по-прежнему отклоняется.

Контекст​

Измерено в коде dev 2026-10-01, до изменения:

  • Где происходит испускание. История: create_story у PO (apps/engine/lib/engine/harness/tools/create_story.ex) вызывает POST /internal/sessions/:id/stories, а создаёт CreateStoryUseCase в api — одинаковый заголовок отклоняется, содержащееся обоснование предупреждает (RN-081). Бизнес-правило: emit_artifact (Criativo и ещё пять разговорных) записывает событие artifact.business_rule через api (append_event), после того как ArtifactDedupe отклонил одинаковый заголовок (RN-080). Таблицы правил нет: правило — ЭТО событие. Ни один из двух серверов (po_server.ex, criativo_server.ex) не пришлось менять — испускание живёт в двух инструментах.
  • Как RAG вызывает embed. Через одну точку, RagEmbeddingService, с ФИКСИРОВАННЫМИ provider и моделью (ollama/nomic-embed-text, 768 измерений — RN-222/233): единственный provider, объявляющий capabilities.embeddings (RN-191). Сбой становится available: false с причиной, никогда исключением. Сервис отбрасывал inputTokens, который возвращает контракт, — отсечение 0075.
  • Есть ли сохранённые векторы. Нет. chunks индексирует chat.message, agent.response, docs/ и ADR; ни одного artifact.*, ни одной истории. Проверке приходится векторизовать на лету.
  • Почему отсечение 0075 не достигает этой проверки. Его причина была структурной: token_usage.session_id — NOT NULL, а индексация репозитория не происходит в сессии. Испускание истории или правила происходит ВСЕГДА внутри сессии агента — session_id существует.

Решение​

  1. Что сравнивается. Новый ЗАГОЛОВОК против заголовков существующих элементов ТОГО ЖЕ типа в ПРОЕКТЕ (история с историей, правило с правилом), по косинусу между векторами модели RAG, вызываемой через ТОТ ЖЕ RagEmbeddingService, — никогда второй путь к embed. Заголовок, а не заголовок+описание: пара из находки R — это заголовки, а описание меняет длину и тон между ходами модели (стоимость и шум без случая, который бы этого требовал). Точный дубликат (одинаковый нормализованный заголовок) — не предмет этого решения: он отклоняется раньше, по RN-080/081, и поэтому тоже исключается из сравнения.
  2. Только ПРЕДУПРЕЖДАЕТ. Проверка выполняется ПОСЛЕ записи: история и правило существуют с предупреждением или без. Предупреждение возвращается агенту как часть результата инструмента (create_story, emit_artifact) и остаётся в log как backlog.semantic_duplicate_warned, называя похожий элемент и число.
  3. Порог — 0,80, НЕОТКАЛИБРОВАННАЯ ОТПРАВНАЯ ТОЧКА, как четыре числа гибридного поиска 0080. «Начиная с»: равное предупреждает. См. «Что не было измерено». Ошибка в меньшую сторону стоит лишнего предупреждения, которое ничего не блокирует; поэтому предварительное число уместно здесь и не было бы уместно в отказе.
  4. Без embedding provider проверка ПРОПУСКАЕТСЯ и ОБЪЯВЛЯЕТСЯ. Отсутствие capability, демон недоступен, модель не скачана или потолок времени — всё становится status: skipped с причиной, описанной как backlog.semantic_duplicate_check_skipped и во фразе агенту. Никогда не проваливает испускание, никогда не молчит: без этого «нет предупреждения» читалось бы как «не дубликат».
  5. Расход попадает в metering ОТДЕЛЬНОЙ СТРОКОЙ. Через единственный путь, RecordLlmUsageUseCase, с актором system/duplicata-semantica (вне сумм по агентам, RN-038, и расходов области, ADR 0110), provider/модель RAG, output_tokens = 0, input_tokens и estimated — из сказанного provider, цена каталога, когда модель в нём есть (иначе 0, с нулевым model_id). RagEmbeddingService начинает ВОЗВРАЩАТЬ суммарное использование (uso); индексация и поиск его игнорируют, и именно это сохраняет отсечение 0075 для них.
  6. Потолки. Не более 100 САМЫХ СВЕЖИХ существующих (TETO_DE_COMPARACOES_DE_DUPLICATA), одним пакетом; когда срезает, результат говорит, сколько из скольких (RN-180). И 10 с по часам на всю проверку (TETO_DE_TEMPO_DA_CHECAGEM_MS), ниже 15 с Req engine, который её вызывает: таймаут Ollama в установке достигает 300 с, а проверка стоит на пути инструмента агента.
  7. Без migration. Ничего не сохраняется: ни вектор, ни новый столбец. Отдельная строка в token_usage отличается актором, который уже существует.

Последствия​

Стоимость испускания начинает расти вместе с проектом. Каждое испускание векторизует min(N, 100) + 1 заголовков, N = существующие того же типа. Заголовок истории или правила — от 5 до 15 слов; с токенизатором nomic-embed-text это ОЦЕНИВАЕТСЯ в 10–30 токенов на заголовок, то есть до ~3 000 входных токенов на испускание на потолке — с локальным Ollama цена каталога 0 и стоимость 0 micros; растёт ЗАДЕРЖКА, здесь не измеренная. Реальное число даёт metering, ради этого строка и существует. Хранение вектора каждого элемента (столбец в stories, таблица для правил-событий) сократило бы это до одного вызова на испускание, и это migration — остаётся до тех пор, пока этого не потребует измеренная стоимость.

Пара из находки R перестаёт оставаться незамеченной — если порог верен. И это «если» — пробел этого решения (ниже).

У фразы предупреждения ОДИН источник, fraseParaOAgente в api; engine лишь передаёт её. Одна и та же проверка служит обоим типам.

Что по-прежнему проходит. Дубликат, чей смысл в ОПИСАНИИ, а не в заголовке («Saudação com nome» × «Quem chama pode se identificar», пример из RN-080), не сравнивается. Предупреждение, потерянное из-за таймаута, объявляется, но проверка не повторяется. А budget с policy = 'block' (RN-019) не проверяется ДО этого вызова: строка прибавляется к расходам и может пересечь пороги, но проверка не отклоняется из-за превышенного бюджета — она ничего не блокирует, а с локальным Ollama записанная стоимость — 0. Если появится платный embedding provider, этот выбор вернётся на стол.

Чего этот ADR НЕ меняет: отказ при точном дубликате (RN-080/081), отсечение metering индексации и поиска (ADR 0075), фиксированные модель и provider RAG (ADR 0080) и ни одного разговорного сервера.

Что не было измерено​

Решение владельца требовало откалибровать порог на паре из находки R и на парах, которые НЕ являются дубликатами, доказав тестом с записанными векторами. Окружение, в котором родилось это изменение, не достигало registry Ollama (registry.ollama.ai: 403 в proxy) и Hugging Face (403), и в нём не было демона — измерено 2026-10-01. Вектор, написанный вручную, доказал бы руку, а не модель. Поэтому существует следующее:

  • apps/api/test/fixtures/duplicata-semantica/pares.json — калибровочные пары: пара из находки R и две перефразировки, которые ДОЛЖНЫ предупреждать; пять пар соседней темы, которые НЕ должны.
  • apps/api/scripts/gravar-vetores-de-duplicata.ts — записывает vetores.json против реального Ollama, с моделью, которую, по словам демона, он использовал, и печатает косинус каждой пары относительно действующего порога.
  • apps/api/test/domain/backlog/limiar-de-duplicata.calibracao.spec.ts — запускается сама, когда существует vetores.json: каждая пара duplicata предупреждает, каждая distinta проходит. Без файла она ПРОПУСКАЕТСЯ с названным предупреждением (линейка smoke embedding Ollama), никогда не проходит молча.

Пока кто-нибудь не запишет векторы, 0,80 — это общее знание о моделях embedding фраз, а не измерение. Если запись покажет, что ни одно число не разделяет две стороны, ответ — пересмотреть пары или сравниваемый текст, никогда не ослаблять тест.