Создание приложения для сопоставления резюме с помощью tRPC, NLP и Vertex AI

от автора

Создание приложения для сопоставления резюме с помощью tRPC, NLP и Vertex AI

Создание приложения для сопоставления резюме с помощью tRPC, NLP и Vertex AI

Недавно я сделал небольшое приложение на 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 и ограничения.

  1. tRPC Official Site – Move Fast and Break Nothing. End-to-end typesafe APIs made easy. (Фокус tRPC на full-stack TypeScript и типизацию).

  2. Viljami Kuosmanen, Comparing REST, GraphQL & tRPC (dev.to, Oct 2023) – Обсуждается, как tRPC предоставляет RPC-стиль и делится типами вместо универсальной схемы.

  3. Bryant Gillespie, REST vs. GraphQL vs. tRPC (Directus blog, Feb 2025) – Описывает сильные стороны tRPC (минимум шаблонного кода, типизация) и его ограничения (только TypeScript, ограниченный охват).


ссылка на оригинал статьи https://habr.com/ru/articles/943306/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *