De idea a producto: así construí un AI Assistant para mi portfolio
Cuando estás buscando nuevas oportunidades profesionales, uno de los procesos más tediosos es adaptar tu CV para cada oferta de trabajo. Cada empresa busca palabras clave diferentes, cada descripción de puesto enfatiza aspectos distintos, y terminar reescribiendo el mismo documento una y otra vez es una pérdida de tiempo valioso.
Por eso decidí integrar un AI Assistant directamente en mi portfolio-blog. No es un chatbot genérico: es un asistente especializado que entiende mi perfil profesional, conversa conmigo sobre cada oferta de trabajo, y genera versiones adaptadas de mi CV en PDF listas para enviar.
En este post te cuento cómo lo implementé, qué arquitectura tiene, y qué ventajas reales ofrece para todo el proceso de reclutamiento.
El problema
El flujo tradicional de búsqueda de empleo se ve más o menos así:
- Encuentras una oferta interesante
- Copias la descripción del puesto
- Abres tu CV en un editor
- Reescribes el summary, ajustas las descripciones de experiencia
- Exportas a PDF
- Envías
- ...repites para la siguiente oferta
Cada iteración toma entre 30 y 60 minutos. Si estás aplicando a 10-15 puestos, son horas dedicadas a una tarea mecánica que un LLM puede hacer mejor y más rápido, siempre que lo configures correctamente.
Arquitectura general
El feature vive en una app Django dedicada llamada cv_assistant, que se integra con el portfolio existente (de donde extrae la información del CV base) y con un proveedor LLM compatible con la API de OpenAI.
┌─────────────────────────────────────────────────────────┐
│ Portfolio-Blog (Django 5.2) │
│ │
│ ┌──────────────┐ ┌──────────────────┐ ┌─────────┐ │
│ │ portfolio │ │ cv_assistant │ │ blog │ │
│ │ (CV base) │──▶│ (AI Assistant) │ │ │ │
│ └──────────────┘ └───────┬──────────┘ └─────────┘ │
│ │ │
│ ┌───────▼──────────┐ │
│ │ Services Layer │ │
│ │ ┌─────────────┐ │ │
│ │ │ ai_client │──┼──▶ LLM API │
│ │ ├─────────────┤ │ (OpenAI-compat)│
│ │ │ cv_adapter │ │ │
│ │ ├─────────────┤ │ │
│ │ │ cv_builder │ │ │
│ │ ├─────────────┤ │ │
│ │ │pdf_generator│ │ │
│ │ └─────────────┘ │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────┘
La app se divide en tres capas claras:
- Modelos: estructuran los datos del proceso de reclutamiento
- Servicios: encapsulan la lógica de negocio (adaptación de CV, prompts, generación de PDF)
- API (DRF): expone endpoints RESTful para que el frontend vanilla JS los consuma
Modelos de datos: el dominio del reclutamiento
Diseñé cuatro modelos que capturan el ciclo completo de una aplicación de trabajo:
1 2 3 4 5 6 7 | |
JobApplication es el agregado raíz. Representa una oferta a la que estoy aplicando. El campo job_description contiene el texto completo del anuncio, que es lo que el LLM usará como contexto para adaptar el CV. El status fluye: draft → cv_generated → sent → responded.
1 2 3 4 5 | |
ChatMessage guarda la conversación con el AI sobre cada oferta. Esto permite mantener contexto entre sesiones y revisar qué decisiones se tomaron.
1 2 3 4 5 6 7 8 9 | |
CVVersion es el corazón del feature. Cada vez que genero un CV adaptado, se crea una nueva versión con versionado incremental, se guarda el summary adaptado y las experiencias reescritas (como JSON estructurado), se registra qué modelo de IA lo generó, y se almacena el PDF resultante.
1 2 3 4 5 | |
RecruiterResponse cierra el ciclo. Permite registrar el feedback del reclutador (entrevista, rechazo, oferta) y vincularlo a la versión específica del CV que se envió, creando un funnel de conversión medible.
Integración con el LLM: provider-agnostic por diseño
Una de las decisiones de arquitectura más importantes fue no acoplarse a un proveedor específico de IA. El cliente está construido sobre el SDK de OpenAI, pero funciona con cualquier proveedor que implemente la API spec:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
Toda la configuración vive en settings.py vía variables de entorno:
1 2 3 4 5 | |
Esto significa que puedo cambiar de OpenAI a DeepSeek, a GLM de ZAI, o a cualquier otro proveedor, simplemente cambiando tres variables de entorno. Sin tocar una sola línea de código.
Prompt engineering: dos system prompts, dos trabajos distintos
Aquí es donde el feature pasa de "chatbot genérico" a "asistente especializado". La decisión de arquitectura más importante que tomé después de usarlo en producción fue separar los prompts por responsabilidad: el chat y la generación de CV son dos features distintas y merecen prompts distintos.
El system prompt de generación (el del botón "Generate CV")
Se construye dinámicamente con los datos reales del CV base:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | |
Las reglas 3 y 4 son críticas: el LLM no puede inventar experiencia ni cambiar fechas o títulos. Solo puede reescribir el summary y las descripciones de experiencia para destacar skills relevantes. Esto preserva la honestidad del CV mientras lo optimiza para ATS (Applicant Tracking Systems).
El system prompt del chat (career coach, no generador)
La primera versión del chat reutilizaba el prompt de generación. Resultado: cada vez que preguntaba algo —"¿hago fit para este rol?"— el asistente respondía el análisis y además devolvía el CV completo adaptado. Nadie se lo pidió. Eso son ~1.500-2.000 tokens quemados por respuesta para un documento que ya tiene su propio botón.
El fix fue darle al chat su propio prompt, con una regla que prohíbe explícitamente el dump:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | |
Dos detalles de diseño: el CV y la descripción del puesto van como contexto de solo lectura (con la helper _format_base_cv_data() compartida, así ambos prompts describen exactamente el mismo CV base), y si el usuario le pide al chat que genere el CV, el asistente lo deriva al botón en vez de producirlo.
La lección: si dos features comparten prompt, una termina haciendo el trabajo de la otra. El prompt es un contrato; cada acción de la UI debe tener el suyo.
El user prompt incluye la descripción del puesto y, crucialmente, toda la conversación previa:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
¿Por qué incluir la conversación? Porque el análisis que surge en el chat es oro para la adaptación: si en la conversación identificamos que el rol exige observabilidad y que mi CV está más fuerte en CI/CD, el CV generado debe enfatizar justamente esa faceta SRE (incidentes, MTTR, runbooks). El chat diagnostica; el botón trata. La conversación completa viaja con el prompt para que el LLM que genera el CV sepa qué se discutió y qué gaps se detectaron — con el guardrail de que nada fuera del CV base puede terminar en el documento.
Y la respuesta del LLM se parsea y valida estrictamente:
1 2 3 4 5 | |
El validador cruza cada ID de experiencia devuelto por el LLM contra los IDs reales en la base de datos Django. Si el LLM alucina un ID que no existe, la función levanta un ValueError antes de que llegue al PDF.
El flujo completo: del chat al PDF
Veamos cómo se conecta todo en el endpoint generate-cv:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | |
El versionado usa select_for_update() dentro de una transacción atómica para evitar race conditions si se generan múltiples versiones concurrentemente.
El PDF se genera con WeasyPrint renderizando el mismo template HTML que usa el portfolio para el CV público, pero con los datos adaptados:
1 2 3 4 5 6 | |
La función build_cv_context usa SimpleNamespace para crear objetos duck-typed que el template puede renderizar sin saber si los datos vienen de los modelos Django originales o de la adaptación del LLM. Esto es un patrón elegante de hexagonal architecture: el template no acopla a ninguna fuente de datos específica.
El chat: conversación con contexto completo (y sin CVs sorpresa)
Además de la generación de CV, el assistant mantiene una conversación sobre cada oferta de trabajo. El endpoint messages construye el contexto completo en cada llamada:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
Esto significa que el LLM siempre tiene el contexto del CV base y de la descripción del puesto, sin importar cuántos mensajes haya en la conversación. Puedes preguntar "¿qué skills debería destacar más?" o "¿hago fit para este rol?" y el assistant responde con conocimiento completo de tu perfil y de la oferta — sin regalar el CV adaptado al final de cada respuesta.
Y el círculo se cierra en el otro endpoint: cuando presiono "Generate CV", la conversación completa de ese job application viaja en el prompt como contexto adicional (ver build_adaptation_prompt arriba). El análisis del chat no se pierde — alimenta directamente la adaptación.
Ventajas para el proceso de reclutamiento
1. Velocidad: de 45 minutos a 45 segundos
Lo que antes tomaba casi una hora (leer la oferta, identificar keywords, reescribir secciones, exportar PDF) ahora se hace en menos de un minuto. El LLM lee la descripción del puesto, identifica las palabras clave relevantes, y adapta el summary y las descripciones de experiencia automáticamente.
2. Optimización para ATS
La mayoría de las empresas grandes usan Applicant Tracking Systems que filtran CVs por keywords antes de que un humano los vea. El AI Assistant adapta el lenguaje del CV para incluir los términos exactos que usa la descripción del puesto, aumentando las probabilidades de pasar el filtro automatizado.
3. Versionado y trazabilidad
Cada CV generado se guarda como una CVVersion con número incremental. Puedes generar múltiples versiones para la misma oferta, comparar qué cambió, y registrar qué versión se envió. Si una versión obtuvo una entrevista y otra no, tienes los datos para analizar qué funcionó.
4. Funnel de conversión medible
Con el modelo RecruiterResponse, puedes construir un dashboard que muestre métricas reales:
- Total de aplicaciones enviadas
- Tasa de respuesta (entrevistas / aplicaciones)
- Tipos de respuesta (entrevista, rechazo, oferta)
- Tasa de conversión por versión de CV
El endpoint /api/v1/cv-assistant/jobs/dashboard/ ya devuelve estos agregados listos para visualizar.
5. Multi-idioma automático
Si la descripción del puesto está en español, el CV adaptado se genera en español. Si está en inglés, en inglés. No hay que configurar nada, el prompt lo maneja automáticamente.
6. Sin invención de datos
Las reglas del prompt prohíben explícitamente inventar experiencias, cambiar títulos o fechas. El LLM solo puede reescribir descripciones y el summary. Además, el parser valida cada ID de experiencia contra la base de datos antes de generar el PDF. Es automation honesta, no fabricación.
Frontend: vanilla JS sin frameworks
La UI del assistant es una SPA construida con JavaScript vanilla puro: tres paneles (lista de aplicaciones, chat, y versiones de CV), sin React, sin Vue, sin dependencias npm.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
La autenticación usa session cookies de Django con protección CSRF, no JWT. Esto simplifica el flujo para un admin panel interno sin sacrificar seguridad.
Seguridad
- Todos los endpoints de la API requieren
IsAdminUser(staff auth + CSRF) - La vista del chat usa
UserPassesTestMixincon check deis_staff - La API key del LLM nunca se expone al frontend; vive solo en settings del backend
- El parser valida IDs de experiencia contra la DB antes de renderizar cualquier PDF
Lecciones aprendidas
-
El prompt es el producto. La calidad del CV adaptado depende 90% de cómo están escritas las reglas del system prompt. Iterar sobre las reglas es más valioso que cambiar de modelo.
-
Un prompt por acción de la UI. Cuando el chat reutilizaba el prompt de generación, cada respuesta incluía un CV completo que nadie pidió — tokens quemados y una mala experiencia. Cada acción explícita de la interfaz (chatear, generar CV) merece su propio contrato con el modelo. Y al revés también: la conversación que origina una acción es el mejor contexto para esa acción.
-
JSON estructurado > texto libre. Obligar al LLM a devolver JSON con IDs validables hace que la integración sea confiable y programática, no un best-effort parse de texto.
-
Separación de concerns en servicios. Extraer
cv_builder,cv_adapter,pdf_generatoryai_clientcomo módulos independientes permitió reutilizar el pipeline de renderizado del PDF del portfolio original sin duplicar código. -
Provider-agnostic por defecto. Usar el SDK de OpenAI con
base_urlconfigurable me permite cambiar de proveedor sin tocar código. Cuando un proveedor sube precios o degrada calidad, cambio en segundos.
Conclusión
Este feature convirtió mi portfolio de un sitio estático en una herramienta activa de búsqueda de empleo. No solo muestra mi CV: lo adapta, lo optimiza, lo versiona, y mide resultados. Es un ejemplo concreto de cómo integrar LLMs en un producto real con guardrails de seguridad, arquitectura limpia, y valor de negocio medible.
El código completo está en el repo del portfolio. Si tienes preguntas sobre la implementación, los prompts, o la arquitectura, puedes contactarme directamente.