ADR 0016 — Anamnese: профиль владения навыками, патчи инструкций и замкнутый цикл
- Статус: принят
- Дата: 2026-07-24
- Фаза: 4b (сессия 2 — закрывает Фазу 4)
Контекст
Psicólogo (ADR 0015) уже производит гипотезы с доказательствами и испускает
psychologist.hypothesis_accepted_for_anamnese — событие,
намеренно оставленное без потребителя и задокументированное там как «крючок для 4.6».
Эта сессия строит потребителя: Anamnese — периодическую задачу Oban
на каждый проект, которая выводит proficiency_profile по паре пользователь+компетенция
из окон event log, предлагает instruction_patch в файлах
агентов (с diff, одобрением и rollback) и замыкает цикл
гипотеза→патч→версия.
Критерий приёмки: Anamnese предлагает 1 патч с понятным diff; принятие гипотезы заставляет следующий патч ссылаться на неё; rollback возвращает агента к прежнему поведению.
Решения
1. Ограждение СТРУКТУРНОЕ, а не инструкция в prompt
domain/anamnese/competency-catalog.ts детерминированно выводит разрешённый
каталог: стеки из действующего module_map (нормализованные) +
список процессных компетенций, зашитый в код
(git, agile, arquitetura, testes, seguranca, infra).
validateProficiencyBatch отклоняет ВЕСЬ ПАКЕТ, если любая запись
упоминает что-то вне каталога.
Суть: у модели, которая попыталась бы испустить «тревожность», «психическое здоровье» или «личность», нет пути записи, который бы это принял — запрет не зависит от того, послушается ли prompt. Prompt усиливает, домен гарантирует. Есть явный тест с 8 чувствительными атрибутами.
2. Удаление профиля удаляет по-настоящему — а opt-out не даёт вывести его заново
DELETE строк + одна строка в anamnese_opt_outs. Без opt-out
следующий раунд заново вывел бы ровно тот же профиль, и кнопка
«удалить» была бы косметической. Пользователь снова профилируется только при явном
opt-in. Пользователь с opt-out отфильтровывается в ДВУХ местах (контекст, который
идёт в prompt, и проверка пакета), поэтому он и в prompt не попадает, и
не может стать строкой.
3. История инструкций в отдельной append-only таблице
У agent_instructions был unique(project_id, agent), и она разрушающе увеличивала
version — прежнее содержимое терялось, rollback
был невозможен.
Вместо переделки этой таблицы agent_instruction_versions (новая,
append-only) хранит историю, а agent_instructions остаётся нетронутой
как указатель на «current» — именно его engine читает через
Ecto read-only. Результат: никаких изменений ни в схеме engine, ни в его
тестовой fixture.
Ретроактивный backfill: у всего, что было засеяно до этой фазы, есть
инструкция, но нет ни одной версии в истории. ApplyInstructionVersionService
сохраняет действующее содержимое как версию ПЕРЕД перезаписью, когда
история пуста, — без этого первому rollback некуда было бы
вернуться.
4. Rollback — это операция ВПЕРЁД
Откат к v2 записывает НОВУЮ версию с содержимым v2. Ничто не
удаляется и не переписывается: можно «отменить отмену», а журнал
аудита показывает, когда произошёл каждый откат. Восстановленная версия
сохраняет исходный sourceHypothesisId, так что прослеживаемость
переживает rollback.
ApplyInstructionVersionService — ЕДИНСТВЕННАЯ точка, через которую содержимое
меняется (и одобренный патч, и rollback проходят через неё), что гарантирует,
что ни один путь не забудет записать версию или инвалидировать кэш.
5. Кэш инструкций: match-delete во ВСЕХ корнях
Открытие этой сессии: InstructionFiles.invalidate/3 существовал, но
никогда не вызывался в production — только в тесте. Хуже того, ключ кэша —
{project_id, agent, root}, а root меняется (nil для общего
workspace, путь worktree для dev agents), поэтому инвалидация одного
ключа оставила бы dev с устаревшей инструкцией.
Новые Cache.delete_agent/2 (:ets.match_delete по
{{project_id, agent, :_}, :_}) + InstructionFiles.invalidate_all/2 +
внутренний endpoint POST /internal/projects/:id/agents/:agent/instructions/invalidate,
вызываемый api после каждого патча/rollback.
Известное и принятое ограничение: агенты, которые собирают system prompt
при каждом run (dev-*, QA, SecOps, Psicólogo, Anamnese — типичные цели
патча), получают изменение при СЛЕДУЮЩЕМ выполнении; разговорные
(Criativo/PO/Arquiteto/Infra) замораживают prompt в init и получают его только после
перезапуска. Пересборка prompt в живом GenServer была намеренно
отвергнута: она затронула бы 4 GenServer Фазы 3, которые CLAUDE.md просит
не рефакторить без необходимости, а критерий приёмки закрывается и без
этого. Инвалидация — best-effort: её сбой не отклоняет
патч (содержимое уже в базе).
6. LCS-diff, написанный вручную, без новой зависимости
domain/instructions/text-diff.ts, ~60 чистых строк. Обоснование
(CLAUDE.md требует обосновывать библиотеки): формат вывода уже диктовался
renderer, который УЖЕ существует в ApprovalCard.tsx
({kind: 'add'|'del'|'ctx', content, lineNo}), инструкции — это файлы
от десятков до нескольких сотен строк (n·m тривиально, Myers не
нужен), а репозиторий отдаёт предпочтение чистым протестированным доменным функциям.
ProposeInstructionPatchUseCase уже отдаёт files[].lines в формате
renderer — UI не получил собственного differ.
7. «Не предлагать снова отклонённый патч» без новой таблицы
isDuplicateOfRejected сравнивает с содержимым
proposed_actions типа instruction_patch со статусом denied —
выводится из уже существующих данных, без новой структуры, которую нужно держать
в синхронизации. Нормализация видит сквозь CRLF, пробелы справа и
отступы из пустых строк (шум форматирования), но сохраняет
отступ слева (он может менять смысл в markdown), поэтому изменение отступов
считается другим патчем.
8. Патч инструкции НИКОГДА не одобряется автоматически
Потолок в decide.ts в том же духе, что и блокировка merge: ни
agent_autonomy, ни permissions.json не могут повысить
instruction_patch до auto_approve. Ценность функции в том, что человек
видит diff заранее; автоодобрение означало бы, что агент переписывает сам себя.
9. Цикл замыкается явной очередью, а не outbox
AcceptHypothesisUseCase ставит в anamnese_queue (unique по
hypothesisId, идемпотентно) рядом с событиями, которые он уже испускал. Постановка в очередь
здесь детерминирована — она не зависит ни от маршрутизации outbox, ни от того, работает ли
потребитель. Engine.Anamnese.Triage.should_run?/2 делает так, что
гипотеза в очереди всегда принуждает раунд (даже при тихом окне):
игнорировать её значило бы сломать цикл, который пользователь только что запросил.
Полная цепочка: принятая гипотеза → anamnese_queue → prompt раунда
как «ПРИОРИТЕТНЫЙ input» → propose_instruction_patch с
hypothesisId → payload proposed_action → sourceHypothesisId в
записанной версии → значок происхождения в UI.
10. Периодический раунд: глобальный scheduler с fan-out по проектам
AnamneseSchedulerWorker повторяет в точности идиому OutboxDrainWorker
(без :unique в use — выполняющаяся задача столкнулась бы сама с собой и
убила бы цепочку; unique: только в kickoff/0 с
states: [:available, :scheduled, :retryable]). Тик 15 мин;
ДОЧЕРНИЕ задачи имеют unique по project_id, поэтому медленный проект не
накапливает раунды.
Anamnese привязана к проекту, но append_event/token_usage привязаны к
сессии через FK — scheduler выбирает самую свежую сессию
проекта как адрес для повествования (прецедент: repo_bootstraps использует
выделенную сессию). Проект без единой сессии не запускается.
Раунд, который не завершается, не записывает anamnese_runs, поэтому окно
обрабатывается заново в следующем (та же дисциплина, что у Psicólogo); раунд без
нового материала и без очереди пропускается, не тратя LLM.
Последствия
- Новые
SessionEventRepository.listForProjectInWindow(api) иEngine.SessionEvents.Event.list_for_project_window/4(engine) — запроса по временному окну не было. Engine читает окно прямо из Postgres (дешевле, чем гонять log по HTTP), соединяя сsessions, потому чтоsession_eventsне несётproject_id. - Новые
Engine.Sessions.ProjectSession(read-only) иEngine.Projects.Project.list_ids/0— engine не умел перечислять проекты и находить сессию проекта. - «Команды, которые одобряет/отклоняет» — это не session_events (только outbox + таблица
proposed_actions, где живётrejectionReasonсвободным текстом). В этой сессии окно покрывает пользовательские события log; чтение решений изproposed_actionsостаётся естественным развитием контекста. - UI: две новые секции в
ProjectSettingsTab(профиль с кликабельными доказательствами и кнопкой удаления; история версий с diff и rollback), веткаinstruction_patchвApprovalCardсо значком исходной гипотезы и веткиinstruction.*/anamnese.*вactivity.ts. Engine.Harness.Agents— identity"anamnese"описывала onboarding проекта; переписана под реальную роль (профилирование с доказательствами и явным запретом чувствительных атрибутов).
Охват и допущения
Вне охвата: синхронизация объединения ActionType в web с 12 типами backend
(предсуществующий долг — вошёл только instruction_patch, поскольку у него
собственная отрисовка); пересборка prompt в живом разговорном
GenServer; новый индекс в session_events для окна по проекту (если
сканирование станет узким местом, это станет follow-up); ручное редактирование инструкции
через UI (только предложенный патч + rollback).
Уровень владения — трёхступенчатая шкала (iniciante/
intermediario/avancado) — намеренно грубая: точность более крупной
шкалы не была бы обоснована наблюдательными доказательствами.