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

ADR 0101 — Реляционная память как проекция event log; шаблоны потребляются без трогания GenServers

  • Статус: Принят
  • Дата: 2026-08-20
  • Контекст: потребление фундамента графа (RN-416/417), компаньон ADR 0099/ ADR 0100

Контекст​

Предыдущая волна построила фундамент графа (Neo4j, версионируемые шаблоны, rag_search) без реального потребителя. Эта волна соединяет обе стороны: (1) три агента начинают разрешать kickoff/идентичность из шаблона графа, с fallback на inline-текст; (2) Psicólogo и Anamnese начинают дополнять "N самых свежих"/"временное окно" РЕЛЕВАНТНЫМИ фрагментами проекта через rag_search; (3) граф начинает записываться сам, проекцией event log.

Два вопроса дизайна требовали ответа ДО распараллеливания: как потреблять шаблоны, не переписывая GenServers целиком, и как писать в граф, не открывая второй путь записи из engine.

Решение​

Шаблоны: InstructionFiles получает источник :graph, не меняя контракт. Итоговый приоритет: db > graph > dir > root — instruction_patch пользователя по-прежнему побеждает всё (уже установленное бизнес-правило: пользователь всегда может перезаписать); граф побеждает то, что на диске. PromptAssembler не меняется; ни один GenServer агента не теряет ни строки логики — каждый лишь начинает ПЫТАТЬСЯ разрешить шаблон перед использованием inline-текста, который становится fallback. Кэш ETS переиспользован (никакого нового параллельного кэша), короткий TTL.

Два флага, намеренно РАЗДЕЛЁННЫЕ: graph_templates_enabled? (Psicólogo + Anamnese, один ключ, один потребитель kickoff) и graph_instruction_templates_enabled? (ux-designer, через InstructionFiles). Один ключ столкнулся бы с ПРОТИВОПОЛОЖНЫМИ значениями по умолчанию между двумя направлениями, которые их писали параллельно, — а разрешение config/2 в Elixir с дублированным ключом берёт ПОСЛЕДНЕЕ вхождение, заглушая одно из двух БЕЗ КАКОЙ-ЛИБО ОШИБКИ. Два ключа с безошибочно различимыми именами дешевле, чем согласовывать единое значение между параллельными направлениями. Оба значения по умолчанию — false: новая возможность рождается выключенной, пока не выполнится seed и кто-то сознательно её не включит — тот же критерий, что у psychologist_enabled?/anamnese_enabled?.

Релевантность: композиция, никогда замена. Psicólogo и Anamnese продолжают читать то, что всегда читали (недавние события / временное окно), и ПОЛУЧАЮТ второй источник, rag_search, с запросом, выведенным из ТРИГГЕРА анализа (классифицированная причина завершения, у Psicólogo; компетенции ещё без профиля, у Anamnese — никогда свободный текст гипотезы/колебания, по уже установленному запрету для Anamnese никогда не выводить здоровье/личность/возраст/пол). Существующие пределы Triage (max_prompt_events, max_payload_chars) ОСТАЮТСЯ общим бюджетом — релевантные фрагменты вычитают места из окна недавних, никогда не добавляются сверху. degraded: true из RAG появляется явно в собранном контексте, у обоих агентов — никогда не скрыто. Сбой RAG строго аддитивен: без попадания поведение ИДЕНТИЧНО тому, что было до этой волны (именно это сохранило ~50 существовавших тестов зелёными, не тронув ни одного).

Реляционная память: проекция СУЩЕСТВУЮЩЕГО outbox, собственный aggregate_type. Очевидная альтернатива — engine пишет в граф напрямую — отброшена: она открыла бы ВТОРОЙ путь записи помимо event log, нарушая гарантию, что event log — единственный источник истины. Альтернатива переиспользовать aggregateType: 'session', который у outbox уже есть, тоже отброшена: Engine.Outbox.Drain на стороне engine уже опустошает этот тип каждые ~2 с и отмечает processed_at — потребитель на стороне api, соревнующийся за тот же тип, почти всегда проигрывал бы гонку. Решение: ВТОРАЯ строка outbox, та же транзакция, aggregateType: 'graph_projection' — значение, которому фильтр engine (IN ('session', 'task')) никогда не соответствует, тот же шаблон, что deny-action.use-case.ts уже использует для записи в два aggregateType в одной транзакции. GraphProjector (poller, ~2 с, той же формы, что DomainGaugesCollector) опустошает эту очередь и вызывает уже существующие use cases записи (RecordHandoff, RecordHypothesis, RecordAnamneseProfile, RecordInteraction) — идемпотентность живёт В НИХ, projector не дублирует логику.

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

  • Граф остаётся восстановимым через replay: graph_projection — ПРОИЗВОДНАЯ очередь event log, никогда не вторая первичная запись — потерять Neo4j и пересоздать с нуля — это replay, а не потеря данных.
  • GraphUnavailableError посреди пакета ОСТАНАВЛИВАЕТ весь цикл (не пытается обработать остаток пакета, который упал бы по той же причине) — строка остаётся необработанной и повторяется в следующем цикле. Повтор автоматический, без вмешательства.
  • Потребление объявлено ВНЕ этой поставки, по тому же шаблону честности предыдущих ADR: query_user_context (гипотезы со свидетельствами + профили графа) по-прежнему без открытого HTTP-маршрута — Psicólogo/Anamnese ещё не ЧИТАЮТ из графа, только RAG (pgvector) через rag_search; проецируется только psychologist.hypothesis_proposed (не accepted/ dismissed); context-manager-summarize — единственный из четырёх шаблонов первой партии, ещё не потребляемый ни одним агентом.
  • Оба флага (graph_templates_enabled?, graph_instruction_templates_enabled?) остаются false во всех окружениях, пока кто-то не запустит seeder и сознательно не включит, — эта поставка сама по себе не меняет наблюдаемого поведения в продакшене.