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

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) — намеренно грубая: точность более крупной шкалы не была бы обоснована наблюдательными доказательствами.