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

ADR 0115 — Pull модели из Hugging Face: собственная таблица, подтверждение в два шага и allowlist официальных издателей — никогда proposed_actions

  • Статус: Принят
  • Дата: 2026-08-26
  • Контекст: позволяет owner/maintainer workspace искать в Hugging Face Hub и делать pull GGUF-модели в локальный демон Ollama из Configurações do Projeto/Workspace, закрывая пробел, на который у пути локальной LLM не было ответа (получить модель, которой нет среди немногих встроенных в entrypoint compose)
  • Ссылки (без редактирования): ADR 0042 («никогда не активировать автоматически обнаруженную модель» — правило, которое это решение применяет к новой поверхности), ADR 0100 (Ollama как локальный демон, принимающий pull), исходный ADR FASE 1, который ввёл proposed_actions и decide()

Контекст​

У продукта уже есть конвейер «агент предлагает, человек решает»: proposed_actions, permissions.json/decide() и три экрана, которые читают из него (Aprovações, чат сессии, Insights). Pull модели из Hugging Face в Ollama на поверхности казался тем, что мог бы туда поместиться, — это действие с реальным внешним эффектом (загрузка в несколько гигабайт, затем активация модели, на которую другие агенты могут тратить), и продукт хочет обдуманного подтверждения в два шага перед его выполнением, а не одного клика.

Не помещается, и несоответствие видно только тогда, когда прослеживаешь, вокруг чего конвейер был на самом деле построен.

Решение​

huggingface_model_pull_requests — новая маленькая таблица, а не proposed_actions​

Прежде чем что-либо строить, механизм проследил четыре вещи, которых требует proposed_actions, и проверил, действуют ли они здесь:

  • proposed_actions.session_id — NOT NULL (FK на sessions, cascade). У этой функциональности нет сессии — она вызывается из Configurações do Projeto/Workspace, а не из хода агента внутри сессии. Втиснуть её значило бы сфабриковать сессию для действия, не имеющего к ней отношения.
  • actor_kind/actor_id фиксируют, кто предложил (user/agent/system), а resolved_policy — это всегда вывод запуска decide() против permissions.json/agent_autonomy — этап разрешения политики, построенный для уровней автономии (auto_approve/require_approval/deny) над поведением агента. Здесь актор, запрашивающий pull, и есть человек — уже ограниченный @RequireRole('maintainer') на маршруте (apps/api/src/interfaces/http/llm/huggingface-models.controller.ts:63-72), — и нет агента, за которым надо надзирать. Пропуск через decide() применил бы семантику уровня автономии к случаю, у которого нет уровня автономии: maintainer, повторно подтверждающий собственный запрос.
  • action_type проверяется по закрытому объединению ActionType в decide.ts, и правило самого CLAUDE.md для этого объединения прямолинейно: новому типу нужна фраза в apps/web/src/lib/aprovacoes.ts, и он появляется на трёх экранах, построенных для надзора за агентами. Сфабриковать там запись для «человек нажал подтвердить в собственном действии в Configurações» значило бы выставить её в UI, построенном для ответа на другой вопрос («что агент просит меня одобрить»), ради действия, которое никогда этот вопрос не задаёт.
  • «Второе явное подтверждение», которое нужно этой функциональности, — это барьер UX/безопасности над прямым действием человека, а не барьер одобрения предложения агента — другая форма «вы уверены», а не та же.

Учитывая это, загнать это в proposed_actions означало бы сгибать механизм, построенный для надзора за недоверенным агентом, над случаем без агента — связывая функциональность с семантикой permissions.json/agent_autonomy, которая ей не нужна и никогда не проектировалась для ответа на неё.

Новая таблица, huggingface_model_pull_requests (миграция 0053_optimal_sauron.sql): id, workspace_id (FK workspaces, cascade), requested_by (FK users), repo_id, estimated_size_bytes (nullable — никогда не угадывается, когда Hub его не публикует), status (huggingface_model_pull_status: pending_confirmation → confirmed → pulling → active | failed), confirmed_at, failed_reason (всегда с префиксом из словаря источников ADR 0020 — infra/modelo/código/ política), метки времени и индекс по workspace_id (apps/api/src/db/schema.ts:2371-2393).

Подтверждение в два шага ЕСТЬ машина состояний, а не лишний флаг​

RequestModelPullUseCase (apps/api/src/application/use-cases/llm/huggingface/request-model-pull.use-case.ts:23) создаёт строку в pending_confirmation и больше ничего не делает — ни одного сетевого вызова, ни одного побочного эффекта. Только отдельный вызов ConfirmModelPullUseCase (.../huggingface/confirm-model-pull.use-case.ts:45), достижимый исключительно через POST .../pull-requests/:id/confirm, переводит строку в confirmed → pulling и действительно вызывает OllamaProvider.pullModel. Нет ни третьего флага, ни колонки, фиксирующей «это было подтверждено», — сам переход pending_confirmation → confirmed и есть второе подтверждение, а ConfirmModelPullUseCase отказывается (409, ConflictException) выполнять его дважды: строку, которая больше не в pending_confirmation, нельзя подтвердить снова.

repoId передаётся в теле запроса, а не в маршруте​

Реальный формат id в Hub — <publisher>/<model> (напр.: meta-llama/Llama-3.1-8B-Instruct-GGUF) — / внутри того, что было бы сегментом пути :repoId, ломает сопоставление маршрута с одним сегментом в Nest/Express. POST .../pull-requests получает его в теле (RequestModelPullDto), тот же выбор, что уже сделан для путей файлов в apps/api/src/interfaces/http/git/code.controller.ts по той же причине.

Pull выполняется синхронно внутри подтверждающего HTTP-запроса​

ConfirmModelPullUseCase ждёт всю загрузку, прежде чем вернуться. У api нет исполнителя фоновых jobs, достижимого из неё, — Oban живёт в engine на Elixir, и синхронного командного канала из api к нему для этого нет. Запустить pull и сразу вернуться означало бы потерять необработанное исключение в момент возврата вызывающей функции, и markFailed в catch use case никогда бы не выполнился. GET .../pull-requests/:id существует для тех, кто не хочет держать это соединение открытым (обратный прокси или шлюз часто истекают по времени раньше, чем завершится загрузка в несколько гигабайт), — сам pull продолжает выполняться на сервере, опрашивает его кто-то или нет.

Allowlist официальных издателей — правило ADR 0042, применённое к новому знаку​

HUGGINFACE_OFFICIAL_PUBLISHERS (apps/api/src/domain/llm/huggingface-official-publishers.ts:17-26) — это короткий, вручную отобранный и чувствительный к регистру список организаций Hub, признанных фактическим производителем того, что они публикуют (meta-llama, google, mistralai, microsoft, Qwen, deepseek-ai, openai, nvidia), — та же позиция «никогда не активировать автоматически обнаруженную модель, курирование всегда ручное», которую ADR 0042 уже держит для каталога LLM, применённая здесь к знаку «официальный» вместо активации. isOfficialPublisher сравнивает сегмент издателя в repoId точно, без нормализации регистра: сам Hub чувствителен к регистру (Qwen, а не qwen), а нормализация позволила бы перезаливке в нижнем регистре — которая не является официальной организацией — случайно получить знак.

SearchHuggingFaceModelsUseCase (.../huggingface/search-huggingface-models.use-case.ts:28) фильтрует до результатов official: true по умолчанию; includeCommunity: true возвращает всех издателей, каждый помечен official/нет, и никогда не скрывает различие. Сторона web (apps/web/src/components/HuggingFaceModelBrowser.tsx:190-203) рисует этот выбор как переключатель, выключенный по умолчанию, плюс постоянный явный баннер-предупреждение в тоне опасности (Alert tone="danger"), который появляется только пока показываются результаты сообщества, — opt-in и предупреждение о риске — один и тот же жест, никогда не настройка, которую кто-то включает однажды и забывает, что она включена.

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

Отклонение формата маршрута, которое должен соблюдать вызывающий. repoId — это поле тела, никогда не интерполируется в путь URL, — задокументировано на маршруте и повторено здесь, чтобы будущая правка не «упростила» его обратно в параметр пути и не вернула дефект / внутри сегмента.

Реальный и объявленный срез: без очереди. Синхронный pull — намеренный обмен, а не небрежность, — отмечен в самом комментарии use case как достойный ADR, который этот ADR закрывает. Настоящая фоновая очередь в api — естественный следующий шаг, если объём pull когда-либо оправдает дополнительную сложность; ничто здесь не мешает построить её позже.

Модели, загруженные через pull, получают цену и активацию так же, как любая другая локальная модель Ollama. ConfirmModelPullUseCase вставляет в models с ценой 0/manualPricing: false (повторяя трактовку, которую sync-model-catalog.use-case.ts даёт любой другой локальной записи Ollama) и активирует модель только для запросившего workspace через WorkspaceModelRepository.setActive. Это не нарушение правила «никогда не активировать автоматически обнаруженную модель» из ADR 0042 — то правило касается фоновой синхронизации, обнаруживающей то, о чём никто не просил; здесь активация — прямое и прослеживаемое следствие того, что человек дважды запросил эту конкретную модель (запрос, затем подтверждение).

Отображаемое имя — сырой repoId. displayName загруженной модели — это то, как её назвал Hub (напр.: meta-llama/Llama-3.1-8B-Instruct-GGUF), никогда не переформатированное — оставлено как вопрос продукта/UX на потом, здесь не решён.

Ответ поиска Hub сегодня не несёт оценки размера. Оценка, показываемая при подтверждении, берётся только из того, что клиент уже знал, когда вызвал POST .../pull-requests (estimatedSizeBytes, всегда null, когда не сообщено), — endpoint поиска её не заполняет, поэтому экран подтверждения обычно будет показывать «não informado pelo Hub» вместо реального числа. Это согласуется с объявленным правилом продукта никогда не фабриковать оценку; это не баг, а реальное текущее ограничение того, что открывает ответ поиска Hub.

Рассмотренные альтернативы​

  • Переиспользовать proposed_actions. Отклонено по причинам, прослеженным в Решении выше: нет сессии, к которой её привязать, этап разрешения политики, построенный для уровней автономии агента, неприменим к запросу самого человека, закрытый словарь ActionType, чьи UI-поверхности построены для другого вопроса, и «второе подтверждение», которое является барьером безопасности прямого действия, а не барьером одобрения/отклонения чужого предложения.
  • Булев флаг confirmed в одношаговом запросе вместо машины состояний. Отклонено: форма из двух запросов (create, а затем отдельный вызов confirm, ограниченный ролью на обоих маршрутах) — это то, что делает подтверждение явным и пересматриваемым: флаг, переключённый в том же запросе, что и создание, схлопнул бы два шага, которые продукт явно хочет разделить, а отказ ConfirmModelPullUseCase подтверждать повторно прямо вытекает из моделирования этого как состояний, а не флага.
  • Сопоставление издателя без учёта регистра для официального знака. Отклонено: сам Hub трактует имена издателей как чувствительные к регистру, а нормализация отдала бы знак «официальный» перезаливке имени официальной организации в нижнем регистре — ровно тот тип имени, который менее аккуратный сторонний издатель выбрал бы намеренно.