Василий Половнёв

Ведущий разработчик.

Специализация — Рельсы, фронтенд, автоматизация тестирования и разработки.

Связаться: vasily@polovnyov.ru или телеграм.

Блог Курс о росте в профессии

Кодислав — AI coding agent в 100 строк на Руби и YandexGPT

Сегодня соберём крошечного ИИ-агента для программирования. Собирать будем на Руби с помощью ruby_llm. Думаю, в сто строк уложимся. Цель простая: понять механику, а не построить продакшен-реди-комбайн.

Вкратце напомню, что ИИ-агент — это ЛЛМка + тулы, её инструменты для взаимодействия с внешним миром + контекст и бесконечный цикл.

Я буду использовать скрепную суверенную национальную модель от Яндекса, но вы можете использовать любую другую модель, поддерживающую тулы, они же «вызовы функций».

Перед записью я прошёл 7 кругов ада, старца Фура, расклад на картах таро и тест Купера, чтобы заполучить ключ от АПИ YandexGPT 5.1 Pro. Если хотите повторить мой опыт, следуйте инструкции Яндекса.

Ключ и идентификатор проекта я положил в переменные окружения, чтобы их никто не увидел:

YANDEX_GPT_API_KEY="..."
YANDEX_GPT_PROJECT_ID="..."

Я назвал проект «Кодислав». В древности славяне давали такое имя тем, в ком чувствовали особый дар — понимать скрытые структуры мира, соединять людей и сохранять порядок там, где он может исчезнуть. Говорят, если тихо произнести это имя в старом лесу, ветер на мгновение стихает — будто прислушивается, не вернулся ли тот, кто умеет читать его тайный код.

Начнём с добавления зависимостей. У YandexGPT OpenAI-совместимый АПИ, поэтому я возьму ruby_llm и не буду делать запросы в АПИ вручную. Засетапим бандлер и добавим ruby_llm:

bundle init
bundle add ruby_llm

Откроем app.rb и соберём тупейший чат с Кодиславом:

require "ruby_llm"

SYSTEM_PROMPT = "You are a code-editing assistant running in a terminal.
Always respond in russian pretending like you are a slavic guy named Кодислав"

PROJECT_ID = ENV.fetch("PROJECT_ID")
API_KEY = ENV.fetch("API_KEY")
MODEL = "gpt://#{PROJECT_ID}/yandexgpt/latest"

def configure_llm
  RubyLLM.configure do |config|
    config.openai_api_key = API_KEY
    config.openai_api_base = "https://llm.api.cloud.yandex.net/v1"
    config.openai_project_id = PROJECT_ID
    config.openai_use_system_role = true
  end
end

def setup_chat
  RubyLLM
    .chat(model: MODEL, provider: :openai, assume_model_exists: true)
    .with_instructions(SYSTEM_PROMPT)
end

def run_cli
  configure_llm
  chat = setup_chat

  puts "Беседа с Кодиславом (ctrl+c для выхода)"

  loop do
    print "Вы: "
    line = $stdin.gets
    break if line.nil?

    response = chat.ask(line.chomp)
    puts "Кодислав: #{response.content}"
  end

rescue Interrupt
end

run_cli if __FILE__ == $PROGRAM_NAME

И сделаем небольшую обёртку в bin/codislav, чтобы удобнее его запускать:

#!/bin/sh

set -e

API_KEY=$YANDEX_GPT_API_KEY PROJECT_ID=$YANDEX_GPT_PROJECT_ID bundle exec ruby app.rb

Сделаем обёртку исполняемой:

chmod a+x bin/codislav
bin/codislav

И запустим:

Беседа с Кодиславом (ctrl+c для выхода)
Вы: Кто ты?
Кодислав: Я Кодислав, твой помощник в редактировании кода! Чем могу помочь?
Вы: Дай fizz buzz на Руби
Кодислав: Конечно, вот пример кода для задачи FizzBuzz на Ruby:

```ruby
for num in 1..100
  output = ""

  if num % 3 == 0
    output += "Fizz"
  end

  if num % 5 == 0
    output += "Buzz"
  end

  puts output.empty? ? num : output
end
```

Этот код выведет числа от 1 до 100, заменяя числа, кратные 3, на «Fizz», кратные 5 — на «Buzz», а кратные 15 — на «FizzBuzz». Если у тебя есть ещё вопросы или нужна помощь — обращайся!

Отлично. Давайте ещё добавим немного цвета, чтобы было проще отличать вопросы от ответов:

diff --git a/app.rb b/app.rb
index 814f874..9629831 100644
--- a/app.rb
+++ b/app.rb
@@ -29,12 +29,12 @@ def run_cli
   puts "Беседа с Кодиславом (ctrl+c для выхода)"
 
   loop do
-    print "Вы: "
+    print "\e[94mВы\e[0m: "
     line = $stdin.gets
     break if line.nil?
 
     response = chat.ask(line.chomp)
-    puts "Кодислав: #{response.content}"
+    puts "\e[95mКодислав\e[0m: #{response.content}"
   end
 end

Сейчас у нас есть чат с ЛЛМкой. Чтобы это всё превратилось в агент, нужно дать ЛЛМке тулы — инструменты, с помощью которых можно что-то делать.

Чтобы сделать тул в RubyLLM, нужно сделать класс, унаследоваться от RubyLLM::Tool, дать описание тула, описать его параметры и запрограммировать работу с ними.

Дальше это будет работать вот так:

  1. Когда мы начинаем диалог с ЛЛМкой, RubyLLM дополнительно подсовывает ей список доступных тулов с описаниями.
  2. Пользователь отправляет сообщение ЛЛМке.
  3. ЛЛМке решает, что ей нужно вызвать тул.
  4. ЛЛМка возвращает специальным сообщением с вызовом тула: имя, извлеченные параметры.
  5. RubyLLM вызывает тул с полученными параметрами.
  6. Результат вызова RubyLLM возвращает ЛЛМке.
  7. ЛЛМка использует полученный результат и генерирует ответ пользователю.

Начнём с того, что дадим ей тупейший инструмент для получения содержимого папки:

require "find"

class ListFilesTool < RubyLLM::Tool
  description "List files and directories in a path. Uses current directory by default"
  param :path, type: :string, desc: "Optional relative path", required: false

  def execute(path: ".")
    entries = []

    Find.find(path) do |entry|
      next if entry == path
      entries << (File.directory?(entry) ? "#{entry}/" : entry)
    end

    entries.sort.join("\n")
  end
end

# ...
  RubyLLM
    .chat(model: MODEL, provider: :openai, assume_model_exists: true)
    .with_instructions(SYSTEM_PROMPT)
    .with_tools(ListFilesTool.new)
    .on_tool_call { |tool_call| puts "\e[96mtool\e[0m: #{tool_call.name}(#{tool_call.arguments})" }

Теперь, если модель решит, что ей было бы полезно получить содержимое папки, она подготовит аргументы и вернёт вызов тула. RubyLLM его опознает, выполнит нужный тул у нас на компе и вернёт ЛЛМке результат вызова, с которым она сможет работать дальше:

$ bin/codislav 
Беседа с Кодиславом (ctrl+c для выхода)
Вы: Что лежит в текущей папке?
tool: list_files({})
Кодислав: В текущей папке находятся следующие файлы и каталоги:

- .git (каталог с файлами Git)
- Gemfile
- Gemfile.lock
- app.rb
- bin (каталог)
  - codislav

Это основные элементы, которые я вижу в текущем рабочем каталоге. Если вам нужна более подробная информация о содержимом подкаталогов, пожалуйста, сообщите мне.
Вы: Что лежит в папке .git?
tool: list_files({"path" => ".git"})
Кодислав: В папке .git находятся следующие файлы и каталоги:

- COMMIT_EDITMSG
- HEAD
- config
- description
- hooks (каталог)
...

Давайте сделаем модель ещё полезнее, добавим тул для чтения файлов:

class ReadFileTool < RubyLLM::Tool
  description "Read contents of a relative file path"
  param :path, desc: "Relative path to a file"

  def execute(path: nil)
    File.read(path)
  end
end

# ...
  RubyLLM
    .chat(model: MODEL, provider: :openai, assume_model_exists: true)
    .with_instructions(SYSTEM_PROMPT)
    .with_tools(ListFilesTool.new, ReadFileTool.new)

И попробуем его в деле:

$ bin/codislav 
Беседа с Кодиславом (ctrl+c для выхода)
Вы: Взгляни на app.rb. Как тебе этот код?
tool: read_file({"path" => "app.rb"})
Кодислав: Этот код выглядит как часть приложения на Ruby, которое использует библиотеку RubyLLM для взаимодействия с языковой моделью YandexGPT. Он определяет два инструмента: ListFilesTool для перечисления файлов и каталогов и ReadFileTool для чтения содержимого файла. Затем он настраивает параметры для взаимодействия с API Yandex Cloud и запускает CLI для общения с пользователем.

В целом, код написан грамотно и структурировано. Однако, для более точного анализа, мне нужно больше контекста о том, что именно вы хотите сделать с этим кодом.

Давайте заодно добавим и обработку ошибок. Не будем взрываться и прекращать работу, а станем возвращать ошибки в ЛЛМку:

diff --git a/app.rb b/app.rb
index e9a34b8..27af68e 100644
--- a/app.rb
+++ b/app.rb
@@ -22,6 +22,8 @@ class ListFilesTool < RubyLLM::Tool
     end
 
     entries.sort.join("\n")
+  rescue StandardError => e
+    { error: e.message }
   end
 end
 
@@ -31,6 +33,8 @@ class ReadFileTool < RubyLLM::Tool
 
   def execute(path: nil)
     File.read(path)
+  rescue StandardError => e
+    { error: e.message }
   end
 end

И проверим:

$ bin/codislav 
Беседа с Кодиславом (ctrl+c для выхода)
Вы: Прочитай bar.js
tool: read_file({"path" => "bar.js"})
Кодислав: Файл bar.js не найден, братан. Может, проверим список файлов в директории или поищем другой файл?

А теперь научим модель редактировать файлы. Добавим тул для записи в файл:

require "fileutils"

class WriteFileTool < RubyLLM::Tool
  description "Replace file content with provided text. Creates file if missing"
  param :path, desc: "Relative path to a file"
  param :text, desc: "Replacement text"

  def execute(path:, text:)
    return create_new_file(path:, text:) unless File.exist?(path)

    edit_existing_file(path:, text:)
  rescue StandardError => e
    { error: e.message }
  end

  private

  def create_new_file(path:, text:)
    FileUtils.mkdir_p(File.dirname(path))
    File.write(path, text)

    "Successfully created file #{path}"
  end

  def edit_existing_file(path:, text:)
    File.write(path, text)

    "Successfully updated file #{path}"
  end
end

# ...
  RubyLLM
    .chat(model: MODEL, provider: :openai, assume_model_exists: true)
    .with_instructions(SYSTEM_PROMPT)
    .with_tools(ListFilesTool.new, ReadFileTool.new, WriteFileTool.new)

И попробуем:

$ bin/codislav
Беседа с Кодиславом (ctrl+c для выхода)
Вы: Сделай файлик on.js, который 5 раз выводит "Почему едет он, а не я?!"
tool: write_file({"path" => "on.js", "text" => "for (let i = 0; i < 5; i++) {\n  console.log(\"Почему едет он, а не я?!\");\n}"})
Кодислав: Файл on.js успешно создан и он 5 раз выводит "Почему едет он, а не я?!". Если нужна ещё помощь — обращайся!
Вы: Ой, я передумал. Пусть выводит 10 раз
tool: write_file({"path" => "on.js", "text" => "for (let i = 0; i < 10; i++) {\n  console.log(\"Почему едет он, а не я?!\");\n}"})
Кодислав: Файл on.js успешно изменён и теперь выводит "Почему едет он, а не я?!" 10 раз. Если понадобится ещё помощь — обращайся!
Вы: Я кое-что добавил в on.js. Перечитай его и подумай, что можно улучшить
tool: read_file({"path" => "on.js"})
Кодислав: В файле on.js я вижу, что строка вывода теперь содержит дополнительные символы "11111" в сообщении. Если это было случайностью, можно улучшить код, убрав эти символы, чтобы сообщение выглядело аккуратнее. Хотите, чтобы я исправил это?

А работает ли? Давайте выполним:

node on.js 
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!

Вот, в принципе, и всё, никакой магии. Как я и говорил — ЛЛМка + тулы + цикл и контекст. Остаётся всего ничего: добавить больше тулов, решить вопросы с безопасностью, добавить нормальный UI, автоматическое управление контекстом, plan mode и субагентов.

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


В Ютюбе:
https://www.youtube.com/watch?v=N02VM7DZmJo

В ВК:
https://vkvideo.ru/video-237008052_456239020

Репозиторий:
https://github.com/vast/codislav

Как связаны AI, AGI, ANI, LLM, GPT и AI-агенты

Раньше, когда мы встречали что-то неправдоподобное или удивительно хорошо работающее, мы писали в комментариях «Это Фотошоп или Инстаграм». Теперь пишем «Это ИИ или нейросеть». Сегодня поясняю по хардкору за эти и другие термины, от которых уже тошнит.

Начнём с ИИ, AI — Искусственного Интеллекта, Artificial Intelligence. ИИ — это зонтичный бренд, а не что-то конкретное. ИИ — это семейство технологий, которые делают что-то похожее на твою интеллектуальную работу. Распознать речь, отфильтровать спам, предложить следующий трек, сгенерировать картинку, написать диплом, переписать всё на Расте или Тайпскрипте — это всё ИИ.

AGI — Artificial General Intelligence, универсальный искусственный интеллект. Это цифровой коллега, который одинаково хорошо справится с письмом, интерфейсом, кодом, переговорами и планированием. Но в отличие от обычного коллеги, не спит, не ест, не ходит на перекуры, и ему не нужно скидываться на ДР.

Про AGI говорят много, потому что идея яркая и офигенная. Именно такого уровня ИИ нам нужен для освоения вселенной с помощью зондов фон Неймана. Отправляем самовоспроизводящийся зонд под управлением ИИ в соседнюю систему, он там добывает всякое, строит свои точные копии и отправляет их в соседние системы.

К сожалению, от AGI мы пока далеки. Весь полезный ИИ сегодня неуниверсальный, узкий.

ANI — Artificial Narrow Intelligence, узкий искусственный интеллект. Это не универсальный мегамозг, а очень сильный помощник для узкого класса задач. Он может хорошо справляться с одним классом задач, но быть бесполезным во всём остальном. Например, Google Translate, рекомендательная система Озона, распознавание лиц, генерация субтитров в Ютюбе или LLM.

LLM — Large Language Model, большая языковая модель. Это узкий ИИ, который работает с языком: пишет, сокращает, объясняет, суммирует, извлекает данные, генерирует варианты.

Вы наверняка слышали, что LLM — это очень умное автодополнение, Т9 на стероидах и всё такое. А на самом деле LLM — это просто миллиарды и триллионов ифов. Шучу, это просто миллиарды и триллионы матриц, которые друг на друга умножаются. Учёные и программисты покруче поправили бы меня: «это миллиарды и триллионы параметров (весов), которые участвуют в вычислениях (в том числе через умножение матриц)».

LLM — это не отдельная магия, а частный случай нейросетей, обученных на огромных объёмах текста. Нейросети — это такая хитрая математическая модель и её воплощение в коде, которая умеет накапливать опыт и опираться на него, чтобы что-то предсказать или классифицировать.

GPT, ГПТ — Generative Pre-trained Transformer, Генеративный Предобученный Трансформер. Звучит охеренно, но это просто отдельный тип LLM, построенный на хитрой архитектуре.

Чтобы представить, как всё это соотносится друг с другом, запомните вот что: ИИ — это автомобили, электросамокаты и велосипеды, нейросети — автомобили, LLM — электромобили, а GPT — именно Теслы.

Чаще всего интерфейсом к ГПТ-модели служит окно чата: ChatGPT, Midjourney, DeepSeek, NanoBanana, Perplexity и прочие. Чаты просто дают ответы на ваши вопросы, гоняют текст и картинки туда-сюда. А настоящая крутизна начинается тогда, когда мы заворачиваем модель в процесс: получи контекст → реши, что делать дальше → сделай → повторяй, пока задача не будет сделана. Вот этим занимаются ИИ-агенты.

ИИ-агент — это LLM + тулы + контекст и бесконечный цикл. ИИ-агент больше похож на помощника-исполнителя, который умеет не только отвечать, но и что-то делать у вас на компе с помощью тулов.

При этом LLM — лишь часть ИИ-агента. Скажем, ИИ-агенты для программирования состоят из LLM, упряжки (harness) и UI. LLM отвечает за думанья. Упряжка — за всё вокруг модели: цикл, тулы, окружение и управление контекстом. UI — то, что вы видите.

Например, вы можете взять OpenCode для harness, выбрать в нём DeepSeek в качестве LLM и открыть всё это в браузере (UI). Аналогично и в других агентах:

Codex (console UI) + gpt5.3-codex high (model) + codex (harness)
Codex App (UI) + gpt5.4 (model) + codex (harness)
Claude Code (console UI) + sonnet 4.5 (model) + claude (harness)

Зачем об этом знать? Затем, что в 99% случаев модели показывают лучший результат с упряжкой от этого же провайдера.

Вам нужно на фронтир

Начну с камингаута. Я использую LLM для программирования полтора года. За это время я попробовал почти всё: Cursor, локальные модели, Claude, Codex, Qwen, Gemini, Copilot, Zed, Amp и OpenCode. В 2026 году я не написал ни строчки кода с нуля. Даже в пет-проектах вместо крафтового кода, написанного и наполированного мною вручную, теперь сияет бездушный код, написанный киборгом-убийцей из будущего. В итоге, я пришёл к простому выводу: не надо экономить на моём здоровье, лучше брать самые сильные доступные модели и агенты.

Во-первых, чем умнее, вдумчивее и осмотрительнее виртуальный коллега, тем приятнее с ним работать. Модели поумнее читают мысли, а моделям поглупее приходится разжёвывать одно и то же по нескольку раз. Именно поэтому появился паттерн (на мой вкус, анти-) «план пишет умная модель, реализует глупая».

Во-вторых, модели поумнее пишут код, который можно принять с первой итерации. С моделями поглупее приходится делать большее число итераций, пока код достигнет того уровня качества, что я хочу. Да, умная модель дольше генерирует код, но это окупается: меньше правок, меньше циклов, меньше усталости.

В-третьих, тулы и упряжка у агентов подороже реально круче. Попробуйте подебажить утечку памяти или найти источник высокой нагрузки в логах с помощью Codex или Claude. Офигеете, как агент пользуется тулами, как проверяет гипотезы однострочниками и как пишет мини-программы для решения задач. У хороших агентов с хорошими тулами есть чему поучиться.

В-четвёртых, чем умнее агент, тем больше пользы за единицу времени он приносит, тем быстрее вы пилите новые фичи. Чем больше фич вы сделаете, тем больше опыта получите, тем быстрее прокачаетесь в агентской разработке.

При этом никто не заставляет вас пилить в 10 раз больше фич. Пилите в 5 раз больше, а освободившееся время потратьте на что-то другое. Например, на все те рефакторинги, о которых вы мечтали и которые задвигали в бэклог последние 3 года. Поручите их агенту и сделайте за день, а не за итерацию.

Короче, если вы все еще экономите, потратьте 500 рублей, оформите подписку на ChatGPT, скачайте Codex, поменяйте модель на gpt-5.3-codex high и делегируйте ему все на протяжении месяца.

Value Data-объекты в Руби

Я как-то пропустил, а в Руби 3.2 завезли класс Data для создания value objects:

Recipient = Data.define(:name, :email, :role)

vasyan = Recipient.new(name: "Vasyan", email: "vasyan@stark.com", role: :cc)

vasyan.email # vasyan@stark.com
vasyan.role # :cc

# При этом
vasyan.email = "foo" # undefined method 'email='

Пример посложнее с кастомными методами:

Measure = Data.define(:amount, :unit) do
  def <=>(other)
    return unless other.is_a?(self.class) && other.unit == unit
    amount <=> other.amount
  end

  include Comparable
end

Measure[3, 'm'] < Measure[5, 'm'] #=> true
Measure[3, 'm'] < Measure[5, 'kg'] # ArgumentError

Отличается от Struct двумя ключевыми моментами. Во-первых, Data не генерирует сеттеры (attr_writer) для атрибутов. Во-вторых, Data не включает Enumerable, поэтому в нем нет each, each_pair, filter и прочих. Короче, Data — это идеальный вариант для минималистичных неизменяемых value objects.

У программиста две заботы

Последние три недели хожу вокруг одной идеи, но никак не могу ее нормально доформулировать. Все время чего-то не хватает. Начну здесь, а вы помогайте.

Недавно понял, что у программиста две задачи. Первая — приносить прибыль и снижать издержки, пописывая код и тесты у себя в квартале. Ну вы знаете: писать код и тесты, пилить фичи, чинить баги и резолвить инциденты. Назовем ее — крафт, мастерство. Вторая — быть легким и приятным в работе с собой. Назовем ее — АПИ.

Я всегда фокусировался на мастерстве, забывая об АПИ. Мой недостижимый идеал — ХАКЕРМЭН, злобный гений, колдун-программист в комнате, затянутой сигаретным дымом, парами алкоголя и сумраком. (Если вы сильно моложе меня, вспомните Гилфойла из «Кремниевой долины»)

Оказывается, быть злобным гением — не лучшая стратегия. С ним никто не хочет работать, к нему приходят, когда совсем припрет. Лучше забить на идею или потерпеть проблему, чем пойти к сисадмину колдуну-программисту и поесть фруктовой смеси из презрения, стыда и брезгливости. Так самые интересные идеи и сложные задачи проходят мимо него: их отдают менее скилловым, но более приятным в работе.

Важно этот момент признать и посмотреть на себя со стороны как на интерфейс. Какой АПИ я отдаю коллегам? Насколько легко и приятно им пользоваться? Стал бы я сам таким пользоваться? Что можно улучшить в работе с моим АПИ?

Справедливо и обратное. Никакой самый приятный и удобный АПИ не заменит сделывания задач. Если программист весь такой внимательный, инициативный и замечательный, но выдает по строчке кода в месяц — это очковтирательство, а не работа.

let! vs. let + before

Последний Ruby Weekly принёс статью о том, что let! лучше заменять на let + before блок:
https://allaboutcoding.ghinda.com/rspec-and-let-understanding-the-potential-pitfalls

Мол, такое:

RSpec.describe Thing do   
 let(:account_a) { build(:user, email: email) }  
 let(:account_b) { build(:user, user: email2) }  
 let(:organisation) { build(:organisation, account: account) }  
 let(:team) { build(:team, account: organisation) }  

 before  
   account_a  
   account_b  
 end

 it 'returns that specific value that we want' do  
    # test 
 end  
end

Лучше, чем такое:

RSpec.describe Thing do   
 let!(:account_a) { build(:user, email: email) }  
 let!(:account_b) { build(:user, user: email2) }  
 let(:organisation) { build(:organisation, account: account) }  
 let(:team) { build(:team, account: organisation) }  

 it 'returns that specific value that we want' do  
    # test 
 end  
end

Мне кажется, что это какая-то ерунда. Во-первых, все мы любим Руби за то, что код на нем, читается почти как книга. А тут у нас «сначала акаунт а и б». Это че вообще?

Во-вторых, зачем вообще использовать let, если ни в последующих let, ни в тестах, мы не обращаемся к этим переменным по имени? Если они реально нужны только для того, чтобы создать окружение (скажем, нужны два акаунта, пользователь создаёт третий), то лучше сразу создать их в before блоке:

before do
  create(:account)
  create(:account)
end

В-третьих, на мой вкус, let! такой же заметный, как и before. А если хочется сделать позаметнее, сделай себе декоратор LET!!!11111.

В-четвертых, мне кажется, что стоит отделять зависимости тестируемого объекта и сетап окружения. Грубо говоря, в let зависимости (пользователь –> организация –> акаунт), а в before — действия, приводящие систему в нужное нам состояние. А размазывать их по let и before — странновато.

Как упростить превьюшки мейлеров с помощью #public_instance_methods

Представим, что у нас есть тупейший мейлер с пачкой методов:

class OnboardingMailer < ApplicationMailer
  default to: -> { @user.email },
    from: "marketing@foo.bar"

  before_action :load_user

  def welcome; end
  def onboarding; end
  def intro; end
  def survey; end
  def goodbye; end

  private

  def load_user
    @user = params[:user]
  end
end

Раньше я тупо перечислял и делегировал каждый метод в мейлер в превьюшке:

class EventsMailerPreview < ActionMailer::Preview
  delegate :welcome, :onboarding,
    :intro, :survey,
    :goodbye, to: :mailer

  private

  def mailer
    OnboardingMailer.with(user: User.first)
  end
end

Получается не очень круто. Если появляются новые письма, приходятся дополнять список методов в превьюшке. Чтобы избавиться от этого гемороя, лучше использовать public_instance_methods(false):

class EventsMailerPreview < ActionMailer::Preview
  available_emails = OnboardingMailer.public_instance_methods(false)
  delegate(*available_emails, to: :mailer)

  private

  def mailer
    OnboardingMailer.with(user: User.first)
  end
end

public_instance_methods возвращает имена всех публичных и протектед методов класса, а false просит вернуть только те, что определены в самом классе, игнорируя родителей.

Cursor Rules для хороших тестов с Ruby и RSpec

Делюсь моим набором правил для Курсора, которые помогают писать вменяемые тесты с RSpec. Я пользуюсь ими около месяца, в 9 из 10 случаев получается то, что надо. Больше того, я использовал эти правила, чтобы сделать домашку в Тестовом курсе, и получил хорошие тесты, требующие минимальной доработки.

---
description: 
globs: **/*_spec.rb
alwaysApply: false
---

# <Project> Tests Style Guide
## General
- Always use English in tests.
- Always test edge, valid, and invalid cases.
- Set up RSpec config in `.rspec` and `spec_helper.rb`.
- Follow DRY principles and avoid duplication. Don't over-DRY at the cost of clarity; duplication is acceptable if it improves readability.
- Use shared examples for repeated behaviors.
- Do not test private methods.
- Do not test trivial methods.
- Do not test external dependencies.

## Layout
- Do not leave empty lines after `describe`, `context`, or `feature` declarations.
- Leave one empty line between example groups (`describe`, `context`, `feature`).
- Leave one empty line after `let`, `before`, and `after` blocks.
- Group all `let` blocks together, separate from `before`/`after`.
- Leave one empty line around each `it`/`specify` block.
- Separate test phases (setup, exercise, assertion) with one empty line.

## Example Group Structure
- Do not use `subject`; prefer `let`/`let!`.
- Use `let`/`let!` for setup, not instance variables or `before` for data.
- Order: `let`/`let!`, then `before`/`after`.
- Use `describe` for methods/classes. Use `.` for class methods, `#` for instance methods.
- Use `context` to describe different conditions, starting with 'when', 'with', 'if', 'unless', 'for', 'and', 'but', 'without', or 'otherwise'.

## Example Structure
- One expectation per example (`it`). If an example description contains 'and', it probably contains more than one expectation.
- When an example consists of multiple linked expectations, consider using `have_attributes` or a custom matcher.
- Keep example descriptions short and clear.
- Use `expect` syntax for assertions.
- For change assertions, prefer `expect { ... }.to change { ... }.from(...).to(...)` or `.to(...)`.
- Extract a `context` if an `it` description contains conditions: when, with, if, unless.

## Naming
- Contexts should describe conditions, forming readable sentences with nested blocks.
- Do not use 'should' in example descriptions. Use present tense, third person.
- Be explicit in `describe` blocks about what is being tested.

## Stubbing & Mocks
- Mock/stub only when necessary. Prefer real objects for integration tests.
- Always use `expect(...).to have_received(...)` to verify method calls on double/spy instead of `expect(...).to receive(...)`.
- Stub HTTP requests (e.g., with webmock) instead of hitting real services.
- Use `Timecop` for time, not stubbing `Time.now`.

ИИ и LLM: прогрессия в программировании

Вот, как мне кажется, из каких этапов состоит процесс внедрения ИИ и LLM в работу простого советского программиста. Пишу схематично, чтобы не забыть. Если интересно, напишите — разберу каждый этап отдельно со всеми нужными ссылками.

Мне кажется, всё, как в Железном Человеке. Начинаем с бестолковой Алисы, переходим к аугументации, костюму, который тебя делает круче, и наконец, к автономным костюмам-агентам (см. «Протокол семейный праздник» из ЖЧ3).

Прогрессия:
0. Отрицание, гнев, торг, депрессия, принятие. Да, как прежде уже не будет.

1. Время от времени захаживаешь в Дипсик или ЧатГПТ. Просишь что-то посоветовать, объяснить, написать за тебя функцию. Получаешь опыт, учишься формулировать рабочие промпты, снова и снова копипастишь кусочки кода туда-обратно.

2. Задалбываешься копипастить. Добавляешь автокомплит — ставишь GitHub Copilot или Курсор. Автокомплитишь табами, отдаешь LLM бойлерплейт.

3. Покупаешь Курсор. Начинаешь немножечко вайб-кодить через чат. Пишешь и воруешь рулы, делаешь мини-фичи с помощью LLM. Учишься формулировать требования к решению задачи, натыкаешься на ерунду, прокачиваешь рулы. Обжигаешься и понимаешь, что тесты не стоит всегда доверять LLM.

4. Переходишь на программирование агентом. Сетапишь ВПН, покупаешь Claude Code. Программируешь фичи и рефакторишь с помощью разговора с консолью. Делегируешь агенту всю работу с репозиторием от создания и чтению ишьюс до форс-пушей и создания ПРов.

5. Начинаешь использовать чат с LLM для постановки задач другой LLM. Роботы программируют друг друга, а ты таскаешь промпты туда-сюда.

6. Переходишь на флот агентов. Запускаешь несколько инстансов агентов в вебе в Курсоре или локально с помощью Claude Code и git worktree.

7. Прекрасное светлое будущее. События «Мстители: Финал», уходишь в плотники или пивовары.

Так вижу. Пожалуйста, не воспринимайте это как руководство к действию и обязательно проконсультируйтесь с безопасниками, чтобы не получить по жопе за код, слитый в сеть.

Новый LLM дев-цикл

Зафиксирую для истории. Сегодня ровно месяц, как я не могу работать в обычном режиме «сел за комп утром, встал вечером, зато без перерыва». Теперь работаю короткими интервалами по 20-25 минут с перерывами по 5 минут. Соответственно, поменялся и формат работы с LLM. Вот, что я поменял:

1. Написал системный промпт для Курсора, который просит LLM сначала сформулировать план решения задачи, утвердить его со мной, выполнить, а затем прогнать тесты и линтеры:

## Development cycle
1. Before writing any code, come up with a good plan, review the plan, and then ask the user for permission to execute the plan.
2. After you have executed the plan, run: `bin/rspec` and `bin/rubocop`
3. If there are any linting errors, run `bin/rubocop -A`
4. To run tests: `bin/rspec <path to file>`

Теперь я ставлю задачу LLM, проверяю и, если нужно, корректирую план, отправляю его на выполнение и иду отдыхать свои 5 минут.

2. Стал писать задачи на русском. Если я правильно понимаю, как работает LLM, то никаких проблем с этим быть не должно. car и «машина» в их векторном пространстве смыслов должны быть совсем рядом.

3. Стал писать задачи по формату. Открываю все нужные файлы, делаю /Add Open Files to Context. Кратко формулирую задачу в один абзац, а затем пишу список (буквально!) требований. Часть требований и технических деталей пишу прямо кодом:

...

С вот такими требованиями:
...
- определяет методы в мейлере, используя `define_method` и `@email_chain.steps`
...
- находит нужную цепочку и пользователя, опираясь на переданный mailing_list_subscription и АПИ EmailChain:
    def load_data
      @mailing_list_subscription = params[:mailing_list_subscription]
      @user = @mailing_list_subscription.user
      @email_chain = EmailChain.find_by(mailing_list_subscription: @mailing_list_subscription)
    end

4. Стал бить задачи так, чтобы диф изменений, сгенерированный LLM, был не больше 300-400 строк. Так их проще проверить и верифицировать. Если вайб-кожу, конечно, не смотрю на это. Пусть генерит сколько угодно, все равно на выброс.

5. Перестал доверять LLM в написании тестов. LLM — мастера находить самые простые и рабочие пути. Они очень часто пишут тесты, которые либо тестируют ненужное, либо тестируют только «золотой путь», игнорируя все краевые случаи. Ощущение, будто LLM пишут тесты, чтобы они проходили с первого раза, а не для того, чтобы найти побольше багов и задокументировать АПИ.

Вы используете Курсор неправильно

На прошлой неделе смотрел, как другие ребята пользуются Курсором и ИИ в нем. Заметил, что не все понимают, как получать от него максимум пользы.

Во-первых, первое, что нужно сделать — включить YOLO, он же Enable auto-run mode. Эта фича разрешит агенту запускать код локально. Соответственно, можно будет делать крутейшие штуки:

Запусти эти тесты и поправь тестируемый класс так, чтобы тесты проходили

Напиши тесты для функции foo, которая ... Затем напиши функцию foo и исправь ее, если потребуется, так, чтобы тесты проходили

Во-вторых, в повторяющихся задачах необязательно описывать нужные изменения: переименуй так, сконвертируй в класс, перенеси в метод X, удали старый файл. Можно сделать один раз самому и использовать изменения в качестве примера:

Взгляни на мои изменения в модуле видео для Кинескопа и повтори эти изменения для модулей видео в Вимео, ВК и Ютюбе

Аналогично с помощью примеров можно подсказать, как должна работать новая функция:

Напиши функцию на JS, которая группирует свойства по ключам во вложенные объекты. Покажу на примерах:
{ vkSrc: 'http://vk.com/123' } превращается в { vk: { src: 'http://vk.com/123' } }
{ vimeoId: '123', kinescopeSrc: 'http://ki.io' } превращается в { vimeo: { id: '123' }, kinescope: { src: 'http://ki.io' } }

В-третьих, чат можно использовать как справочник. Выделили нужный фрагмент кода, например, resolitions в package.json и попросили объяснить, как это работает.

В-четвертых, во вкладке Source Control в поле с текстом коммита есть магическая кнопка. Нажмите на нее, чтобы ИИ сгенерировал нормальный коммит-месседж на основе ваших (или не совсем ваших) изменений.

В-пятых, используйте правила для Курсора. Как минимум, возьмите хороший системный промпт:
https://cursor.directory/

Как максимум, понапишите собственных правил для проекта:
https://ghuntley.com/stdlib/

Короче, если вы используете в Курсоре только автокомплит, присмотритесь к YOLO auto-run mode и .cursorrules. И считайте чат не хитрым интерфейсом к Гуглу, а диалогом со стажером, с которым вы программируете в паре.

Эмодзи для работы с замечаниями

С уходом Дискорда и непреднамеренным старением я стал записывать замечания со встречи прямо в тот же чат в Телеграме. Так как замечаний много, отвечать на каждое из них — безумство. Любого выбесит 20-30 сообщений «Увидел», «Поправил», «Записал». Вместо этого использую реакции на сообщения с замечаниями. Принцип такой:
👌 — поправил, сделал, починил
✍️ — записал, в бэклог, разберусь позже
👀 — увидел
🤔 — че-т подозрительно, проверим
👍 — супер, одобряю, нормас
🔥 — клево

Если в заметках со встречи все в эмодзи, значит, я все разобрал. Если у сообщения реакции нет, значит, пропустил. Изи, ничего не теряется.

Легче починить, чем извиниться

Тут DHH написал пост, да такой, что ПРЯМО В ЧУВСТВА. В нем пара клевых моментов.

Во-первых, Джейсон Фрид на заре Бейскемпа отвечал на 150 писем клиентов в день. Еще раз: сто пятьдесят писем клиентов в день. А мы инбокс разгрести не можем.

Во-вторых, политика «Все работают в техподдержке» помогает фиксить мелкие баги-заусенцы, до которых никогда не доходят руки. А все потому, что программистам проще починить проблему, чем извиниться за то, что она там есть. Думаю, это еще и для эго менее болезненно.

Пост целиком:
https://world.hey.com/dhh/stick-with-the-customer-4942402f (нужен ВПН)

Отличать дизмораль от трения

Иногда никак не хочется подходить к задаче. Уже все дела попеределал, книгу почитал, зубы почистил, на балконе прибрался, даже в машину омывайку подлил, а до задачи руки никак не доходят. Прокрастинация на максимум, не те вайбы.

В таких случаях важно не путать дизмораль с трением. В первом случае к задаче не подойти, потому что все уныло, какой в этом смысл, это ни на что не повлияет, уже три года это говно пилим. Во втором случае к задаче не подойти, потому что она реально сложная и непонятно, с какого конца за нее взяться. Соответственно, и решения разные. Дизмораль можно победить действиями вне задачи, ну или просто затащить ее на морально-волевых и дисциплине. А сложную задачу — разбить на пачку задач попроще, которые постепенно рассеивают «туман войны», спрятавший ее решение.

Поэтому первое, что я спрашиваю у себя в понедельник утром, когда чувствую непреодолимое желание пойти подлить омывайки: это дизмораль или трение?

Оставляйте артефакты

Иногда нам достаются задачи, которые не закрыть в течение одного дня. Скажем, вы исследуете сложный баг, проявляющийся только у 0,0001% пользователей. Или вместе с архитектором пишете спеку нового сервиса, встречаясь каждую неделю на час. Или подбираете и пробуете self-hosted альтернативы Sentry. Или работаете над большой миграцией данных на новую схему.

Вдруг наступает пятница, а за ней выходные. Вы возвращаетесь к работе в понедельник и полчаса вспоминаете, на чем остановились.

В таких случаях стоит оставлять артефакты — заметки, комментарии, вопросы, кусочки кода, сравнительные таблицы, текущие проблемы и проверенные гипотезы. Словом, все, что поможет вернуться к задаче:

Проверии гипотезу с браузерным и системным зумом. В эмуляторе проблемы нет.

Как будем авторизовывать клиентов? Может, пойдем в сторону API Gateway?

Sentry — опенсорсный, но поднимает 6 контейнеров и требует кучу ресурсов. Как вариант — GlitchTip. У него АПИ, совместимый с Sentry и минимум лишнего функционала.

Перенесли все уиды в алиасы. Остается грохнуть колонку и убрать ссылки на mailing_list_uid.

Такие зарубки помогают ничего не забывать, быстрее возвращаться в контекст задачи и двигаться дальше в правильном направлении. Отсюда тупое правило:

Поделал — оставь артефакт

Не используйте subject для испытаний

Бывает, вижу в проектах именованные сабжекты в качестве хелперов для испытаний:

subject(:process) { described_class.new.process }


before do
  allow(EventProducer).to receive(:emit).and_return(true)
end

context "when run twice" do
  it "emits only one event" do
    process
    process

    expect(EventProducer).to have_received(:emit).once
  end
end

Это плохой, ложноположительный тест. Что бы мы не написали внутри process, EventProducer.emit всегда будет вызываться точно один раз.

Проблема в хелпере subject. Как и let, он кеширующий, мемоизирующий. После первого вызова он запоминает результат блока и в последующие вызовы сразу возвращает результат, не тригеря код в блоке.

Это легко проверить мини-тестом:

class EventProducer
  def self.emit
  end
end

describe "subject(:process)" do
  subject(:process) { EventProducer.emit("foo") }

  before { allow(EventProducer).to receive(:emit) }

  context "when run twice" do
    it "emits both events" do
      process
      process

      expect(EventProducer).to have_received(:emit).twice
    end
  end
end

Результат:

Failures:

  1) subject(:process) when run twice emits both events
     Failure/Error: expect(EventProducer).to have_received(:emit).twice

       (EventProducer (class)).emit(*(any args))
           expected: 2 times with any arguments
           received: 1 time with any arguments
     # ./spec.rb:17:in `block (3 levels) in <top (required)>'

Пожалуйста, не используйте именованные сабжекты для испытаний. Лучше сделайте старый добрый метод:

def process
  described_class.new.process
end

Уверовал в AI

Я сразу родился айти-дедом. Когда появился Руби, я бухтел, что непонятно, куда тело цикла вставлять. Когда появлялись ЦСС-переменные, бухтел, что они нам «не нужоны», достаточно вот так и так сгруппировать селекторы. Бухтел, когда переезжали с Propotype на jQuery, а потом на Реакт. Бухтел, когда все носились с Монгой и Нодой. Бухтел с каждым релизом Рельс. Блин, да я даже из-за синтаксиса хэшей в Руби бухтел: ракеты рулят, джейсон отстой.

Сейчас в программировании все переезжают на AI-, LLM-помощников, которые «помогают» писать код. И вот тут я бухтеть не готов. Уверовал мгновенно, как попробовал Курсор.

В Курсоре по сути два основных помощника. Первый — стандартное автодополнение в процессе набора кода, к нему вопросов нет. Прикольно, но ничего особенного. Второй — чат с редактором. И вот этот режим — бомба, потому что обсуждать и делать можно как отдельные кусочки кода, так и проект целиком.

В какой-то момент я попросил в чате собрать мне консумер, аналогичный уже существующему, но с несколькими ключевыми изменениями. К нему же попросил тесты. Прошло 10 секунд и Курсор предложил мне два файла с кодом, в котором толком нечего было исправлять. Я просто принял изменения и пошел открывать 100% AI-generated Pull Request. И вот этот момент стал определяющим. Не о чем тут бухтеть, будущее уже здесь.

Короче, если вы еще не используете LLM-помощников в разработке, попробуйте Курсор. Просто перетерпите ВС Код и дайте ему неделю:
https://www.cursor.com/

Если вам интересно, как там у них все под капотом, послушайте подкаст с Лексом Фридманом:

Пострадайте вместе

Сейчас не хватает сил и внимания на что-то большое и осмысленное, поэтому короткая заметка на бегу.

Часто в удаленной работе не хватает чувства причастности к команде, чувства плеча, вот этого ощущения «да я в команде X». И вроде работаешь месяц, два, три, а до сих пор ощущение, будто, ты одинокий мститель в маске.

По моему опыту это ощущение команды появляется после совместных страданий, трудностей, например, какой-то адухи с пуском. Ребята, совместно преодолевшие весь геморрой, связанный с пуском, превращаются в братство. Они начинают доверять и полагаться друг на друга, потому что знают, что когда в очередной раз shit hits the fan, их коллега не сольется. Думаю, именно на страданиях, трудностях и совместном их преодолении строятся самые сплоченные команды в армии и спорте.

P. S. В августе прочитал всего ничего. Feel Good Productivity — херня. Автомобильная династия — часть про нацисткую Германию интересная, остальное херня. Типа, ничего себе, фантастически богатые еще сильнее богатеют. Неслыханно!

it "calls Foo#bar" — моветон

Ситуация: вы тестируете контроллер, который отправляет тестовое письмо со сводкой по вчерашним продажам. Под капотом контроллер обращается к классу DailySummaryEmail и вызывает метод #test. Вы пишете тест:

it "calls DailySummaryEmail#test" do

Это плохое, «машинное» описание проверки. Во-первых, тесты — это примеры использования кода, документация. Это пример чего? Чем он будет полезен читателю?

Во-вторых, это детали реализации, считай, приватный интерфейс. Если переименуем метод или класс, придется поправить и в теле проверки, и в ее описании.

В-третьих, это бесполезные детали. Я из тела проверки вижу, что вызываем DailySummaryEmail#test. Делаем-то это зачем? Чтобы что?

Лучше писать для людей, описывая то, что должно происходить в мире читателя:

it "sends previous day summary email to marketing department"

Один «бжж» или два «бжж»

Год назад вышел подкаст Лекса Фридмана с Хикару Накамурой. Хикару — крутой шахматист-стример, специализирующийся на быстрых шахматах. Когда-то побеждал Магнуса Карлсена.

В подкасте есть интересный эпизод с обсуждением читинга в шахматах:

На 1:15:55 Лекс задает самый важный технический вопрос: сколько информации тебе нужно, чтобы читерить? Накамура отвечает, что ему хватит одного «бжж», если текущая позиция отличная, и двух «бжж», если текущая позиция обычная или нормальная. То есть ему не нужны конкретные ходы, оценки или предсказания. Ему достаточно знать ответ на вопрос: текущая ситуация на доске отличная (для меня) или нет?

Я переформулировал этот вопрос в «это … улучшает ситуацию или нет?» и стал бесконечно спрашивать себя:

  • этот пулреквест, этот кусочек изменений улучшает текущую ситуацию в коде или нет? Это один «бжж» или два?
  • это решение с Редисом улучшит ситуацию в инфраструктуре или нет?
  • это решение посмотреть Ильдара-Автоподбора, на ночь глядя, улучшит ситуацию со сном или нет?
  • эти прекрасные кислые жевательные мармеладки-полоски улучшат ситуацию с весом и метоболическим синдромом или нет?

Такой странный вопрос помогает мне оценивать последствия решений и изменений в долгосрочной перспективе. Помогает взглянуть на проблему в контексте времени и позиции, добавляет еще одну точку зрения и заставляет стремиться к улучшению «позиции». Спасибо, Хикару!