
Недавно я сделал небольшое приложение на TypeScript, которое сравнивает PDF-резюме с вакансиями. Мне нужен был быстрый способ прототипировать API, поэтому я выбрал tRPC для бэкенда.
tRPC — это RPC-фреймворк с ориентацией на TypeScript, который обещает «end-to-end typesafe APIs» (сквозную типизацию API), то есть я могу делиться типами между клиентом и сервером без написания схем OpenAPI или GraphQL SDL.
На практике это означало, что я мог сосредоточиться на написании логики, а не шаблонного кода. В отличие от REST или GraphQL, tRPC не предоставляет универсальную схему — он просто открывает процедуры (по сути функции) на сервере, которые клиент может вызвать, напрямую разделяя типы входа и выхода.
Почему это полезно? В двух словах, я делал внутренний инструмент (MVP) и уже использовал TypeScript с обеих сторон. Модель tRPC без шагов сборки и с типизацией подошла идеально. В официальной документации tRPC даже отмечено: если я изменю вход или выход серверной функции, TypeScript предупредит меня на клиенте ещё до того, как я отправлю запрос. Это сильно помогает ловить баги на ранних стадиях. В отличие от этого, с REST или GraphQL мне пришлось бы вручную синхронизировать или генерировать схемы. С другой стороны, я понимал, что tRPC жёстко связывает мой API и клиентский код (он не является языконезависимым API), поэтому он лучше всего подходит для проектов «TypeScript-first», как этот, а не для публичных кроссплатформенных API.
Определение роутера tRPC и валидация входных данных
С настроенным tRPC я написал простой роутер для основной операции: анализа двух загруженных PDF-файлов (CV и описания вакансии). Используя tRPC с Zod-form-data, я мог легко валидировать загрузку файлов. Вот упрощённая версия кода роутера:
export const matchRouter = router({
analyzePdfs: baseProcedure
.input(zfd.formData({
vacancyPdf: zfd.file().refine(file => file.type === "application/pdf", {
message: "Only PDF files are allowed",
}),
cvPdf: zfd.file().refine(file => file.type === "application/pdf", {
message: "Only PDF files are allowed",
}),
}))
.mutation(async ({ input }) => {
const [cvText, vacancyText] = await Promise.all([
PDFService.extractText(input.cvPdf),
PDFService.extractText(input.vacancyPdf),
]);
const result = await MatcherService.match(cvText, vacancyText);
return { matchRequestId, ...result };
}),
});
Здесь мутация analyzePdfs
принимает multipart-форму с двумя PDF-файлами. Вызовы zfd.file().refine(...)
гарантируют, что каждый файл — это PDF. После валидации и загрузки файлов (с помощью вспомогательного FileService
) я использую PDFService.extractText(...)
, чтобы извлечь сырой текст из каждого PDF. Затем вызываю MatcherService.match(cvText, vacancyText)
, который выполняет сам анализ. Так как tRPC знает типы входа/выхода, мой фронтенд получает полностью типизированные результаты без написания дополнительных DTO. Такой быстрый сетап и строгая типизация сэкономили мне массу времени на MVP.
Извлечение навыков с помощью базового NLP
Когда у меня был чистый текст CV и описания вакансии, нужно было извлечь из них значимые ключевые слова или навыки. Я сделал это просто: использовал комбинацию natural (для токенизации), compromise (для выделения частей речи, например существительных) и фильтр стоп-слов. Например, в MatcherService
у меня есть такой хелпер:
private static extractSkills(text: string): Set<string> {
const doc = nlp(text);
const nouns = doc.nouns().out("array"); // nouns are often skills or keywords
const capitalizedWords = text.match(/\b[A-Z][a-zA-Z0-9.-]+\b/g) || [];
// also pick up capitalized words (like frameworks or proper nouns)
return new Set([...nouns, ...capitalizedWords].map(w => w.toLowerCase()));
}
Проще говоря, этот код приводит текст к нижнему регистру, пропускает его через NLP-библиотеку compromise, чтобы получить существительные, и с помощью регулярки находит все слова с заглавной буквы (часто это названия технологий). Объединение этих результатов и удаление дубликатов даёт мне набор кандидатов-«навыков» из каждого документа. Это базовое извлечение ключевых слов — не сложный ML, а просто эвристика, но она работает быстро и хорошо подходит для подсветки совпадающих навыков. (Напоминает старые парсеры резюме.) Внешняя модель пока не нужна, достаточно библиотек и небольшой regex-логики в общем сервисе.
Интеграция Vertex AI (Gemini 1.5 Flash) для сопоставления
Для основной логики сопоставления я решил обратиться к Google Vertex AI с новой моделью Gemini 1.5 Flash. Это было нужно в основном для получения структурированного результата сравнения (например, оценка и рекомендации), не реализуя сложную NLP-логику самому. В MatcherService
, после очистки текста и извлечения навыков, я формирую промпт и делаю fetch к Vertex. Например:
const aiPrompt = `
Analyze the job description and candidate's CV to provide a structured evaluation.
Job Description:
${cleanedJD}
Candidate CV:
${cleanedCV}
Provide a structured analysis in JSON format with fields "score", "strengths", and "suggestions".
`;
const response = await fetch(process.env.AI_API_ENDPOINT!, {
method: "POST",
headers: {
Authorization: process.env.AI_API_TOKEN,
"Content-Type": "application/json",
},
body: JSON.stringify({
contents: [
{ role: "user", parts: [{ text: aiPrompt }] }
]
})
});
const data = await response.json();
if (!data?.candidates?.[0]?.content?.parts?.[0]?.text) {
throw new AIServiceError('Invalid AI response format');
}
const rawResponse = data.candidates[0].content.parts[0].text;
// Then parse rawResponse as JSON for score, strengths, suggestions...
Здесь я использую fetch
, чтобы сделать POST на эндпоинт Vertex AI (указан в AI_API_ENDPOINT
), передавая в теле запроса промпт пользователя. Промпт говорит модели сравнить описание вакансии и CV и выдать JSON с оценкой совпадения, сильными сторонами и предложениями. Затем я парсю JSON-текст из data.candidates[0].
content.parts
[0].text
.Этот подход оказался очень удобным — Gemini выдавал результат без написания алгоритма ранжирования. Это ощущается как использование модели в роли «чёрного ящика-сравнивателя». Конечно, это значит, что я доверяю ИИ, и иногда результат требовал очистки или валидации. Но в целом интеграция Gemini позволила сосредоточиться на UI и потоках данных, а не на тонкой настройке LLM. (Пришлось, правда, обрабатывать ошибки и лимиты запросов.)
Почему tRPC подошёл (и его ограничения)
Использование tRPC определённо ускорило разработку. Без необходимости писать схемы API я мог поднять endpoint за считанные минуты. Full-stack TypeScript означает, что код роутера, который я написал выше, — это общий код и для клиента (через генератор клиента tRPC), так что у меня есть проверки во время компиляции. На практике, когда я менял валидацию Zod или возвращаемую структуру, мой React UI сразу переставал компилироваться, пока я не поправлю типы. Это ощущение «автодополнения» — именно то, о чём говорит сайт tRPC. И поскольку tRPC практически не требует шаблонного кода (нет контроллеров, генерации), код оставался лаконичным.
С другой стороны, я знаю про ограничения tRPC. Он жёстко связывает мой фронтенд с этой серверной реализацией, поэтому если бы мне понадобился публичный REST или мобильный клиент, пришлось бы переосмысливать архитектуру. Мне также пришлось самому думать про кеширование и лимиты запросов (tRPC не даёт готового решения, как GraphQL). В блоге Directus очень метко сказано: tRPC отлично подходит для внутренних инструментов на TypeScript, но «ограничивает ваши возможности», если нужна широкая совместимость. Для этого проекта — по сути внутреннего демо — эти ограничения были приемлемы. Я даже добавил простой middleware-лимитер, чтобы мои вызовы Vertex AI не превышали квоту.
Уроки
Создание этого проекта с современными API на TypeScript оказалось довольно приятным. Я получил сквозную типизацию (клиент точно знает, что вернётся, например, { score: number }
) и не пришлось поддерживать отдельную клиентскую библиотеку. Код ощущается очень «SDK-подобным»: я просто вызываю функции в matchRouter
так, словно это локальный код.
На стороне NLP я понял, что даже простые эвристики (существительные + слова с заглавной буквы) могут неплохо справляться с выделением ключевых слов в простых случаях. И наконец, интеграция Vertex AI напомнила, что многое из «магии AI» можно отдать наружным моделям при грамотной постановке промпта.
Но, как всегда, нет серебряной пули. Если бы у меня было больше времени, я бы улучшил обработку ошибок вокруг AI-сервиса и добавил кеширование результатов (так как PDF-to-text и вызовы AI дорогие). И если бы приложение развивалось дальше, я мог бы заменить tRPC на более привычный REST/GraphQL API для публичного интерфейса. Но пока что tRPC дал именно то, что было нужно: быстрый MVP со строгой типизацией и минимумом церемоний.
Полный исходный код доступен здесь: GitHub Repository.
Sources: Я опирался на несколько ресурсов, пока разбирался с этим проектом. На сайте tRPC подчёркивается принцип «move fast, break nothing» и сквозной TypeScript-подход, а в блогах сравнивается, как tRPC выглядит на фоне REST/GraphQL, отмечая его ориентацию на TypeScript и ограничения.
tRPC Official Site – Move Fast and Break Nothing. End-to-end typesafe APIs made easy. (Фокус tRPC на full-stack TypeScript и типизацию).
Viljami Kuosmanen, Comparing REST, GraphQL & tRPC (dev.to, Oct 2023) – Обсуждается, как tRPC предоставляет RPC-стиль и делится типами вместо универсальной схемы.
Bryant Gillespie, REST vs. GraphQL vs. tRPC (Directus blog, Feb 2025) – Описывает сильные стороны tRPC (минимум шаблонного кода, типизация) и его ограничения (только TypeScript, ограниченный охват).
nin-jin
Интересное наблюдение: большая часть статей про использование ИИ, сгенерирована ИИ.