Форм обратной связи у меня накопилось штук двести за месяц. Пользователи писали разное: кто-то хвалил, кто-то жаловался на баг, кто-то присылал запросы на фичи длиной в три абзаца. Всё это лежало в Airtable одной кучей, и каждое утро я открывал таблицу с ощущением, что мне предстоит разгребать почту после отпуска.
В какой-то момент я решил, что хватит. Взял Claude API, написал несколько скриптов — и теперь новые записи обрабатываются сами. Расскажу как это устроено и где я налажал по дороге.
Что вообще нужно было сделать
Задача на первый взгляд простая: каждая новая запись в Airtable должна автоматически получать категорию (баг, фича, вопрос, благодарность), краткое резюме и приоритет. Плюс — если это баг с высоким приоритетом, отправлять уведомление в Slack.
Звучит как час работы. Оказалось — полдня, потому что я сначала пошёл не туда.
Первый инстинкт был — использовать Airtable Automations со встроенным шагом "Run script". Там есть JavaScript-среда, можно делать fetch-запросы. Я уже почти написал всё, когда выяснил, что в Airtable Scripting нельзя хранить API-ключи безопасно: они либо захардкожены прямо в скрипте, либо передаются через поля таблицы. Оба варианта плохие. Развернулся и пошёл делать через внешний сервис.
Архитектура, которая в итоге заработала
Остановился на такой связке: Airtable Webhook → маленький Node.js-сервис на Railway → Claude API → обратно в Airtable через REST API.
Railway выбрал потому что он разворачивается за пять минут и бесплатный tier покрывает такую нагрузку с запасом. Подойдёт любой аналог — Render, Fly.io, даже простой VPS.
Структура сервиса минимальная:
import Anthropic from "@anthropic-ai/sdk";
import Airtable from "airtable";
import express from "express";
const app = express();
app.use(express.json());
const claude = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const base = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY })
.base(process.env.AIRTABLE_BASE_ID);
app.post("/webhook", async (req, res) => {
const { recordId, feedbackText } = req.body;
if (!recordId || !feedbackText) {
return res.status(400).json({ error: "Missing required fields" });
}
try {
const analysis = await analyzeFeedback(feedbackText);
await updateAirtableRecord(recordId, analysis);
if (analysis.category === "bug" && analysis.priority === "high") {
await notifySlack(feedbackText, analysis);
}
res.json({ success: true });
} catch (err) {
console.error("Processing error:", err);
res.status(500).json({ error: "Processing failed" });
}
});
Получаем вебхук, анализируем, пишем обратно. Главное — держать сервис stateless, чтобы не думать о гонках при параллельных запросах.
Как работает запрос к Claude
На промпт я потратил неожиданно много времени. Первые версии давали непредсказуемый формат ответа — Claude мог написать JSON, а мог написать пояснение с JSON внутри, а мог вообще ответить в виде списка. Решение нашлось простое: явно попросить JSON и жёстко валидировать ответ.
async function analyzeFeedback(text) {
const message = await claude.messages.create({
model: "claude-opus-4-5",
max_tokens: 300,
messages: [
{
role: "user",
content: `Проанализируй фидбек пользователя и верни JSON строго в таком формате:
{
"category": "bug" | "feature" | "question" | "praise",
"priority": "high" | "medium" | "low",
"summary": "одно предложение, максимум 20 слов",
"sentiment": "positive" | "neutral" | "negative"
}
Фидбек: "${text.replace(/"/g, '\\"')}"
Верни только JSON, без объяснений.`,
},
],
});
const raw = message.content[0].text.trim();
// Claude иногда оборачивает JSON в markdown-блок
const jsonStr = raw.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
const parsed = JSON.parse(jsonStr);
// Валидация обязательных полей
const validCategories = ["bug", "feature", "question", "praise"];
const validPriorities = ["high", "medium", "low"];
if (
!validCategories.includes(parsed.category) ||
!validPriorities.includes(parsed.priority)
) {
throw new Error(`Invalid response structure: ${raw}`);
}
return parsed;
}
Один момент, который я поначалу проглядел: если в тексте фидбека есть кавычки, неэкранированный JSON в промпте ломает всё. Получал ошибки парсинга на каждом третьем сообщении, пока не разобрался.
Запись обратно в Airtable
async function updateAirtableRecord(recordId, analysis) {
await base("Feedback").update(recordId, {
Category: analysis.category,
Priority: analysis.priority,
"AI Summary": analysis.summary,
Sentiment: analysis.sentiment,
"Processed At": new Date().toISOString(),
});
}
Названия полей должны совпадать с тем, что в Airtable — включая регистр и пробелы. Я минут двадцать отлаживал, почему записи не обновляются, пока не заметил: поле называлось "AI summary" с маленькой буквой, а в коде стояла большая. Airtable API в таком случае не бросает ошибку — просто молча игнорирует поле.
Что добавил позже
После первой рабочей версии я докрутил ещё несколько вещей.
Дедупликация. Airtable может прислать один вебхук несколько раз — такое бывает при сетевых проблемах. Добавил простую проверку: перед обработкой смотрю, не заполнено ли уже поле "Processed At". Если заполнено — пропускаю.
Батчинг. Когда форму заполняют массово — например, после рассылки — прилетает сразу тридцать записей за минуту. Сделал очередь с задержкой между запросами, чтобы не упираться в rate limits Claude API. На бесплатном уровне это особенно актуально.
Логирование ошибок. Сначала просто писал в консоль, потом завёл отдельную таблицу в Airtable — туда пишу recordId, текст ошибки и timestamp. Удобно разбирать постфактум, что пошло не так.
Результат
Пайплайн обработал уже около полутора тысяч записей. Примерно 4% я потом перепроверил вручную — ошибки категоризации нашёл в шести случаях, и почти все были на совсем неоднозначных сообщениях, которые я и сам классифицировал бы по-разному.
Самое неожиданное: я стал реально читать фидбек. Раньше таблица висела как немой укор. Теперь открываю отфильтрованный вид "баги с высоким приоритетом" и вижу три-четыре конкретные записи с готовым резюме. Психологически это совсем другое — не куча текста, а список задач.
По деньгам: одна запись на claude-opus обходится примерно в 0.3–0.5 цента при коротких текстах. Если экономить, можно переключиться на claude-haiku — качество чуть хуже, но для такой задачи разница несущественная, а цена падает в десять раз.
