Я недавно создал небольшое приложение для сопоставления резюме на TypeScript, которое сравнивает PDF-резюме с вакансиями. Я хотел быстро прототипировать API, поэтому выбрал tRPC для бэкенда. tRPC — это фреймворк RPC с приоритетом на TypeScript, который обещает «безопасные для типов API от конца до конца», что означает, что я мог делиться типами между клиентом и сервером, не написав схемы OpenAPI или SDL GraphQL. На практике это означало, что я мог сосредоточиться на написании логики, а не на шаблонах. В отличие от REST или GraphQL, tRPC не раскрывает общую схему — он просто раскрывает процедуры (по сути функции) на сервере, которые клиент может вызывать, делясь типами ввода/вывода напрямую.
Почему это полезно? Короче говоря, я создавал внутренний инструмент (MVP), и я уже использовал TypeScript на обоих концах. Модель tRPC с нулевым шагом сборки и безопасностью типов подошла идеально. Официальная документация tRPC даже хвалит автоматическую безопасность типов: если я изменю ввод или вывод функции сервера, TypeScript предупредит меня на клиенте, прежде чем я даже отправлю запрос. Это было большим успехом для раннего выявления ошибок. В отличие от этого, с REST или GraphQL мне пришлось бы вручную синхронизировать или генерировать схемы. С другой стороны, я знал, что tRPC тесно связывает мой API и код клиента (это не язык-независимый API) — поэтому это лучше всего подходит для проектов «TypeScript-first», таких как этот, а не для публичных кросс-платформенных API.
Определение маршрутизатора tRPC и валидация ввода
С настроенным tRPC я написал простой маршрутизатор для основной операции: анализа двух загруженных PDF-файлов (резюме и описание вакансии). Используя tRPC с Zod-form-data, я мог легко валидировать загрузки файлов. Вот упрощенная версия кода маршрутизатора:
экспорт конст matchRouter = маршрутизатор({
анализироватьPdfs: baseProcedure
.ввод(zfd.formData({
vacancyPdf: zfd.файл().уточнить(файл => файл.тип === "application/pdf", {
сообщение: "Разрешены только PDF-файлы",
}),
cvPdf: zfd.файл().усовершенствовать(файл => файл.тип === "application/pdf", {
сообщение: "Разрешены только PDF файлы",
}),
}))
.мутация(асинхронный ({ input }) => {
константа [cvText, vacancyText] = ожидать Обещание.все([
PDFService.извлечьТекст(input.cvPdf),
PDFService.извлечьТекст(input.vacancyPdf),
]);
константа результат = ожидать MatcherService.совпадение(cvText, vacancyText);
возврат { matchRequestId,...результат };
}),
});
Выше, анализироватьPDF мутация принимает многокомпонентную форму с двумя PDF-файлами. zfd.file().refine(...) вызывает проверку, чтобы каждый файл был PDF. Как только файлы проверены и загружены (через вспомогательный FileService), я использую PDFService.extractText(...) чтобы извлечь сырой текст из каждого PDF. Затем я вызываю MatcherService.match(cvText, vacancyText), который выполняет фактический анализ. Поскольку tRPC знает типы входных/выходных данных, мой фронтенд получает полностью типизированные результаты без необходимости писать дополнительные DTO. Эта быстрая настройка и строгая типовая безопасность сэкономили много времени на MVP.
Извлечение навыков с помощью Basic NLP
Как только у меня был простой текст резюме и описания работы, мне нужно было извлечь значимые ключевые слова или навыки из них. Я сделал это просто: я использовал комбинацию естественный (для токенизации), компромисс (для частей речи, таких как существительные), и фильтр стоп-слов. Например, в MatcherService У меня есть помощник вроде этого:
приватный статический извлечьНавыки(текст: строка): Множество<строка> {
константа док = нлп(текст);
константа существительные = док.существительные().выход("массив"); // существительные часто являются навыками или ключевыми словами
константа заглавныеСлова = текст.совпадение(/\b[A-Z][a-zA-Z0-9.-]+\b/g) || [];
// также захватывать заглавные слова (например, фреймворки или собственные имена)
возврат новый Установить([...существительные,...заглавныеСлова].карта(с => с.вНижнемРегистре()));
}
Проще говоря, этот код переводит текст в нижний регистр, обрабатывает его через компромисс NLP для извлечения существительных, а также использует регулярные выражения для поиска любых заглавных слов (что часто захватывает названия технологий). Объединение этих данных и удаление дубликатов дает мне набор кандидатских «навыков» из каждого документа. Этот базовый процесс извлечения ключевых слов не является сложным ML — это просто эвристика — но он быстрый и хорошо подходит для выделения совпадающих навыков. (Это напоминает мне некоторые старые парсеры резюме.) Внешняя модель пока не нужна, только несколько удобных библиотек и немного регулярных выражений в общем классе сервиса.
Интеграция Vertex AI (Gemini 1.5 Flash) для Сопоставления
Для основной логики сопоставления я решил обратиться к Vertex AI от Google с новой моделью Gemini 1.5 Flash. Это в основном касалось получения структурированного результата сравнения (например, балла и предложений) без необходимости реализовывать сложную логику NLP. В MatcherService, после очистки текста и извлечения навыков, я создаю запрос и получаю данные от Vertex. Например:
константа aiPrompt = `
Анализируйте описание работы и резюме кандидата, чтобы предоставить структурированную оценку.
Описание работы:
${cleanedJD}
Резюме кандидата:
${cleanedCV}
Предоставьте структурированный анализ в формате JSON с полями "score", "strengths" и "suggestions".
`;
константа response = ожидать получить(процесс.среда.AI_API_ENDPOINT!, {
метод: "POST",
заголовки: {
Авторизация: процесс.среда.AI_API_TOKEN,
"Content-Type": "application/json",
},
тело: JSON.строка({
содержимое: [
{ роль: "user", части: [{ текст: aiPrompt }] }
]
})
});
константа данные = ожидать ответ.json();
если (!данные?.кандидаты?.[0]?.содержимое?.части?.[0]?.текст) {
выбросить новый AIServiceError('Неверный формат ответа AI');
}
конст сыройОтвет = данные.кандидаты[0].содержимое.части[0].текст;
// Затем парсите сыройОтвет как JSON для оценки, сильных сторон, предложений...
Здесь я использую fetch для POST-запроса к конечной точке Vertex AI (настроенной в AI_API_ENDPOINT), передавая пользовательский запрос в теле запроса. Запрос говорит модели сравнить описание работы и резюме и вывести JSON с оценкой соответствия, сильными сторонами и т.д. Затем я парсирую текст JSON из данные.кандидаты[0].content.parts[0].text. Этот подход был очень полезен – Gemini выдал результат, не заставляя меня писать алгоритм ранжирования. Это похоже на то, что я рассматриваю модель как черный ящик для сравнения. Конечно, это означает, что я доверяю AI, и иногда вывод требовал очистки или проверки. Но в целом, внедрение Gemini в сервис позволило мне сосредоточиться на UI и потоке данных, а не на запросах к LLM. (Мне пришлось обработать некоторые ошибки и ограничения по скорости вокруг вызова.)
Почему tRPC был хорошим выбором (и его недостатки)
Использование tRPC определенно ускорило разработку. Без необходимости писать схемы API, я мог создать конечную точку за считанные минуты. Полный стек TypeScript означает, что код маршрутизатора, который я написал выше, является общим кодом на клиенте (через генератор клиента tRPC), так что я получаю проверки на этапе компиляции. На практике, когда я изменил валидацию Zod или форму возвращаемого значения, мой интерфейс React сразу же перестал компилироваться, пока я не скорректировал типы интерфейса. Это ощущение «автозаполнения» именно то, что обещает сайт tRPC. И поскольку tRPC по сути не имеет никакого шаблона (нет классов контроллеров, нет генерации кода), код остался лаконичным.
С другой стороны, я осознаю ограничения tRPC. Он напрямую связывает мой фронтенд с этой реализацией сервера, так что если мне когда-либо понадобится публичный REST или мобильный клиент, мне придется пересмотреть это. Мне также пришлось самостоятельно подумать о кэшировании и лимитах скорости (tRPC не делает кэширование из коробки, как это может делать GraphQL). Блог Directus попал в точку: tRPC отлично подходит для внутренних инструментов с большим количеством TypeScript, но он «ограничивает ваши возможности», если вам нужна широкая совместимость. Для этого проекта — по сути, внутреннего демо — эти компромиссы были приемлемыми. Я даже реализовал простое промежуточное ПО для ограничения скорости на случай, если мои вызовы Vertex AI превысят квоту.
Уроки, извлеченные из опыта
Создание этого проекта с современными API TypeScript было довольно приятно. Я получил полную типизацию (клиент точно знает, какая форма { score: number } возвращается) и не нужно поддерживать отдельную клиентскую библиотеку. Код ощущается очень «похоже на SDK», просто вызывая функции на matchRouter, как если бы это был локальный код. С точки зрения NLP, я узнал, что даже простые эвристики (существительные + заглавные слова) могут неплохо справляться с извлечением ключевых слов в экстренных ситуациях. И наконец, интеграция Vertex AI напомнила мне, что много «магии ИИ» можно аутсорсить с помощью хорошо составленного запроса.
Тем не менее, ничто не является панацеей. Если бы у меня было больше времени, я бы улучшил обработку ошибок вокруг AI-сервиса и, возможно, добавил кэширование результатов (поскольку вызовы PDF-в-текст и AI дорогие). И если это приложение вырастет за пределы быстрого демо, я мог бы заменить tRPC на более традиционный REST/GraphQL API, если мне нужен был бы публичный интерфейс. Но пока что tRPC дал мне именно то, что мне нужно: быстрый MVP с полной безопасностью типов и минимальными формальностями.
Вы можете найти полный код для этого проекта здесь: GitHub Репозиторий.
Источники: Я опирался на несколько ресурсов, исследуя эту настройку. Сайт tRPC подчеркивает подход «двигайтесь быстро, не ломайте ничего» с полной безопасностью типов, а блоги сравнивают, как tRPC вписывается среди REST/GraphQL, отмечая его преимущества и ограничения, ориентированные на TypeScript.
- Официальный сайт tRPC — Двигайтесь быстро и не ломайте ничего. Полностью безопасные API с типами стало проще. (Показывает акцент tRPC на полном стеке TypeScript и безопасности типов).
- Вильями Куосманен, Сравнение REST, GraphQL и tRPC (dev.to, октябрь 2023) — Обсуждает, как tRPC открывает функции в стиле RPC и делится типами вместо общей схемы.
- Брайант Гиллеспи, REST против GraphQL против tRPC (блог Directus, февраль 2025) — Охватывает сильные стороны tRPC (минимальный шаблон, безопасность типов) и компромиссы (только TypeScript, ограниченный охват API).


