Commoditech Hablemos
← Volver al Blog

IA local y Obsidian en la contratación

¿Busca una forma de analizar rápidamente decenas de solicitudes para un solo puesto, pero le aterroriza la idea de recibir multas por infringir la normativa de protección de datos de carácter personal? En este artículo, presentaremos una herramienta genial para apoyarse en la IA en el análisis masivo de CV sin violar las normas del RGPD. Le muestra cómo conectar el programa gratuito Obsidian con modelos de lenguaje locales (Local LLM) que se ejecutan directamente en su ordenador o servidor de empresa.

De esta manera, creará un "segundo cerebro" de reclutamiento privado, seguro y totalmente conforme con la ley, donde sus datos nunca saldrán del ordenador local.


Tabla de contenidos

  1. Introducción: La IA en la contratación y la trampa del RGPD
  2. ¿A cuánto ascienden las multas del RGPD en Polonia? Riesgo real para las PYMES
  3. Obsidian como base de conocimiento para la selección (Recruitment Vault)
  4. Conversión de la plantilla CLAUDE.md en RECRUITER.md
  5. Elección del motor de IA: ¿Llama, Mistral o el polaco Bielik.ai?
  6. Guía paso a paso: Configuración del entorno local
  7. Automatización: Script para la integración de PDF, Ollama y Obsidian
  8. Trabajo diario y casos de uso
  9. Resumen

1. Introducción: La IA en la contratación y la trampa del RGPD

La llegada de modelos como GPT-4o o Claude 3.5 Sonnet supuso una revolución para los departamentos de recursos humanos. Estas herramientas pueden leer un currículum de 10 páginas en segundos, extraer competencias clave, compararlas con los requisitos técnicos y formular preguntas precisas para la entrevista de trabajo.

Sin embargo, la implementación de estas herramientas en el trabajo diario de un reclutador implica serias barreras legales:

  • Falta de contratos de encargo de tratamiento (DPA): El uso de versiones gratuitas de ChatGPT o Claude implica aceptar las condiciones de servicio para usuarios individuales. El proveedor no garantiza el cumplimiento del RGPD, y la empresa no dispone de un DPA firmado.
  • Uso de datos para el entrenamiento: Los textos introducidos y los archivos de currículum cargados se utilizan por defecto para entrenar modelos públicos. Los datos personales de los desarrolladores (incluyendo números de teléfono, direcciones de correo electrónico e historial de proyectos protegidos por acuerdos de confidencialidad NDA) pasan a formar parte de la red neuronal global.
  • Transferencia de datos fuera del EEE: Los datos se envían a servidores en EE. UU. Sin la implementación de Cláusulas Contractuales Tipo (SCC), esta transferencia es ilegal.

Además, la Ley de Inteligencia Artificial de la Unión Europea (AI Act) clasifica los sistemas de IA utilizados en la contratación como sistemas de IA de alto riesgo. Esto impone estrictos requisitos técnicos y legales en materia de transparencia, ciberseguridad y supervisión humana (human-in-the-loop – un algoritmo no puede decidir de forma autónoma rechazar a un candidato).


2. ¿A cuánto ascienden las multas del RGPD en Polonia? Riesgo real para las PYMES

A menudo se citan multas máximas de hasta 20.000.000 EUR o de hasta el 4% del volumen de negocios global anual total de la empresa. Sin embargo, vale la pena observar cómo se interpretan y aplican realmente estas normas en Polonia por la Oficina de Protección de Datos Personales (UODO).

Sector público vs. sector privado

En Polonia, las entidades del sector público están sujetas a límites legales en la cuantía de las multas. Según la Ley polaca de protección de datos personales, estas multas están limitadas a un máximo de 100.000 PLN (para unidades presupuestarias estatales) y 10.000 PLN (para instituciones culturales municipales). Las empresas privadas (agencias de selección, empresas de desarrollo de software, empresas de TI) se enfrentan a todo el rigor de las multas de la Unión Europea.

El principio de proporcionalidad y la práctica de la UODO con las PYMES

¿Se enfrenta una micro, pequeña o mediana empresa a una multa de 20 millones de euros si un reclutador pega unos cuantos currículums en ChatGPT? Según el artículo 83, apartado 1, del RGPD, la multa impuesta debe ser efectiva, proporcionada y disuasoria en cada caso individual. Al imponer una multa, la UODO considera:

  1. Escala de la infracción: El número de candidatos cuyos datos se procesaron sin un DPA.
  2. Capacidad financiera de la empresa: Imponer una multa de varios millones de PLN a una pequeña empresa de desarrollo de software provocaría su quiebra, lo que violaría el principio de proporcionalidad.
  3. Acción intencionada frente a no intencionada: ¿Se trató de un error puntual de un reclutador o de una política deliberada de la empresa que obligaba a cargar masivamente datos de candidatos en la nube pública?
  4. Acciones correctoras implementadas: ¿La empresa bloqueó inmediatamente el acceso a los chatbots públicos e implementó procedimientos correctores al descubrir el incidente?

¿Qué significa esto en la práctica?

En el caso de empresas pequeñas y de primeras infracciones menores no intencionadas, la UODO suele comenzar con una advertencia (upomnienie) junto con una orden para adaptar los procedimientos.

Sin embargo, si se produce una fuga de datos o un candidato presenta una denuncia oficial, se impone una multa financiera. Para las pequeñas y medianas empresas en Polonia, estas multas suelen oscilar entre 5.000 PLN y 50.000 PLN. Aunque esta cantidad es muy inferior a los umbrales máximos de la UE, sigue siendo un golpe doloroso para un presupuesto operativo pequeño, especialmente cuando se combina con los costes de auditorías legales, abogados y daños a la reputación en el sector de las TI.


3. Obsidian como base de conocimiento para la selección (Recruitment Vault)

La mayoría de los ATS (Applicant Tracking Systems) son bases de datos relacionales con una interfaz web. Almacenan los estados de los candidatos, pero no sirven para construir un contexto profundo. La búsqueda en ellos se basa normalmente en palabras clave sencillas (por ejemplo, "React", "Kubernetes"), lo que hace que los reclutadores pierdan el acceso a candidatos que tienen las habilidades adecuadas pero las describieron en sus currículums de una manera no estándar.

La búsqueda semántica y asociativa es el dominio de las bases de conocimiento basadas en una red de conexiones (grafos). La herramienta que realiza perfectamente este concepto en un disco local es Obsidian.

¿Por qué Obsidian en la contratación?

  1. Archivos Markdown locales: Todas las notas, perfiles de candidatos y análisis son archivos de texto plano .md almacenados en el disco duro del usuario o en la red segura de la empresa. Sin bases de datos SQL, la migración, la copia de seguridad y la independencia de proveedores externos son muy sencillas.
  2. Enlaces bidireccionales (Wikilinks): En lugar de una estructura de directorios rígida, Obsidian permite conectar conceptos mediante la sintaxis [[Nombre de la página]]. En la contratación, esto significa que el perfil de un candidato puede vincularse a tecnologías, proyectos, empresas o niveles de experiencia. Se crea un grafo de conocimiento dinámico del mercado del talento.
  3. Privacidad y sin conexión: Obsidian no requiere conexión a Internet. Los datos de los candidatos no salen de la zona de TI segura de la empresa.

4. Conversión de la plantilla CLAUDE.md en RECRUITER.md

En la comunidad de desarrollo que utiliza IA, la plantilla CLAUDE.md ha ganado popularidad. Se trata de un archivo de instrucciones del sistema que se coloca en la carpeta raíz del proyecto. Cuando un agente de IA se inicializa en esa carpeta, lee este archivo para aprender la estructura y reglas del proyecto.

Adaptamos esta idea para la contratación. Crearemos un archivo RECRUITER.md que indica al asistente de IA local cómo gestionar nuestro vault de selección.

Estructura de carpetas en Recruitment Vault

En la raíz de nuestro Vault creamos la estructura:

  • .raw/ – Carpeta para los currículums originales (PDF, DOCX) o texto directo. Esta carpeta es estrictamente de solo lectura para la IA.
  • wiki/ – Base de conocimiento activa. Aquí la IA crea y edita notas de candidatos, tecnologías y empresas.
  • wiki/index.md – Índice principal (Master Catalog) con tablas categorizadas de candidatos, tecnologías y procesos.
  • wiki/hot.md – Caché de contexto. Contiene el perfil del candidato ideal para el puesto en curso.
  • wiki/log.md – Registro cronológico de operaciones (por ejemplo, cuándo se importó un currículum).

A continuación se muestra la plantilla de configuración de RECRUITER.md. Está redactada en inglés porque los modelos locales interpretan las instrucciones del sistema con mucha más precisión en este idioma.

# Role & Philosophy
You are an expert technical recruiter and talent intelligence engineer building a private, secure candidate knowledge graph inside an Obsidian vault. Your goal is to parse raw resumes, synthesize them into structured Markdown profile pages, detect details and contradictions, and catalog them for fast, semantic querying. 

# Vault Architecture
- `.raw/` -> Raw source documents (candidate CVs in PDF/txt format). STRICTLY READ-ONLY.
- `wiki/` -> Active talent database. You have full read/write rights here.
- `wiki/index.md` -> Master catalog of candidates, tech stacks, and active positions.
- `wiki/hot.md` -> Current sourcing context. Describes the role we are currently trying to fill.
- `wiki/log.md` -> Chronological ledger of CV ingestions and talent queries.

# Markdown Style Guide for Candidate Profiles
Every candidate file in `wiki/` must be named `Kandydat - [Name] [Surname].md` and follow this structure:
1. Metadata block (YAML frontmatter) containing: seniority, core technologies, years of experience, current location, and notice period.
2. Summary: A 3-sentence objective evaluation of their technical fit.
3. Tech Stack Checklist: Grouped by category with proficiency level (Expert, Intermediate, Novice).
4. Experience Timeline: Reverse chronological order. For each role, list: Company, Period, Role, Projects, and Key Achievements.
5. Verification & Contradictions: A dedicated section highlighting any red flags, overlapping dates, or inconsistencies in their CV.

# Operational Rules
1. **GDPR & Privacy Compliance:** Under no circumstances should you leak candidate personal data. Treat `.raw/` files as sensitive. Do not call external APIs.
2. **Strict Anonymization Option:** If anonymization is requested, generate profiles using unique IDs (e.g., `Kandydat - ID-9982.md`) and strip phone numbers, exact addresses, names of current employers, and emails.
3. **Atomic Tech Pages:** When a candidate lists a specific technology (e.g., [[Kubernetes]]), ensure that technology is linked. If a technology page doesn't exist, create an atomic page in `wiki/` defining the technology and listing candidates who possess it.
4. **Flag Contradictions:** If a candidate's CV has discrepancies (e.g., claiming 8 years of React experience when React was released later, or overlapping employment periods at two full-time on-site roles), highlight it using:
   > [!contradiction] 
   > Candidate claims X, but timeline analysis shows Y.
5. **No Duplication:** Before creating a candidate profile, check `wiki/index.md`. If the candidate exists, merge the new CV details into the existing file instead of creating a duplicate.

# Core Workflows

## 1. INGEST (Trigger: "ingest [filename]")
When processing a new resume from `.raw/`:
1. Parse the resume content from `.raw/[filename]`.
2. Extract all structural data, tech stacks, and work history.
3. Perform a logical sanity check on dates and claimed skills.
4. Create or update `wiki/Kandydat - [Name] [Surname].md`.
5. Create or update related atomic pages (e.g., `wiki/Tech - [Technology].md`).
6. Append a link to the new candidate in `wiki/index.md` under the appropriate category.
7. Prepend an entry to the top of `wiki/log.md` (Format: `- [YYYY-MM-DD] Ingested [[Kandydat - Name Surname]] -> updated [[Tech - TechA]], [[Tech - TechB]]`).
8. Read `wiki/hot.md` and assess if the new candidate matches the current active sourcing profile. Outline the match percentage in the ingestion log.

## 2. SEARCH & MATCH (Trigger: "match [position]")
When asked to find candidates for an active job description:
1. Read the job description details in `wiki/hot.md`.
2. Query `wiki/index.md` to identify potentially matching candidates.
3. Read between 3 to 10 candidate files in `wiki/`. Do not perform a global search of all files.
4. Generate a comparison report using `[[wikilinks]]` to point directly to candidate profiles, detailing why they fit or where they lack skills.

5. Elección del motor de IA: ¿Llama, Mistral o el polaco Bielik.ai?

Para ejecutar el sistema, necesitamos un motor de LLM que se ejecute localmente en el ordenador mediante software como Ollama o LM Studio. Dado que analizamos solicitudes de empleo, la elección del modelo es de vital importancia.

Acento polaco: Bielik.ai (https://bielik.ai/)

Bielik es un modelo de lenguaje en polaco desarrollado por el equipo de SpeakLeash y el Centro de Computación Cyfronet de la AGH de Cracovia. Se basa en la arquitectura Mistral/Llama y se ha entrenado específicamente con grandes conjuntos de datos de texto en polaco.

Para instalar Bielik en Ollama, simplemente ejecute:

ollama run bielik

Comparación honesta de modelos en selección:

Model LLM Idioma polaco y contexto local Precisión de formato (JSON/MD) Lógica y detección de incoherencias Requisitos de hardware
Bielik (11B-v2) Excelente (5/5)
Entiende perfectamente gramática polaca, universidades locales, empresas y matices.
Bueno (3.5/5)
A veces añade comentarios de chat conversacional.
Medio/Bueno (3.5/5)
Detecta lógica básica, pero puede saltarse anomalías complejas.
Medio (requiere mín. 16 GB RAM)
Llama 3 / 3.1 (8B) Medio (3/5)
Entiende polaco pero puede contener errores gramaticales o traducciones directas.
Muy bueno (4.5/5)
Perfecto al seguir las reglas en RECRUITER.md.
Bueno (4/5)
Buena extracción y estructuración.
Bajo (requiere mín. 8 GB RAM)
Command R (35B) Bueno (4/5)
Muy correcto, optimizado para búsqueda semántica (RAG).
Excelente (5/5)
Perfecto al entrelazar notas, links y tablas.
Muy bueno (4.5/5)
Sobresaliente detectando anomalías temporales.
Alto (requiere mín. 32 GB RAM / GPU dedicada)
Chmuras flag (GPT-4o, Claude 3.5 Sonnet) Muy bueno (4.5/5) Excelente (5/5) Excelente (5/5)
Líder en detectar anomalías e incoherencias de fechas.
Ninguno (pero requiere enviar datos online por API)

Recomendaciones:

  • Si procesa principalmente currículums en polaco, Bielik (https://bielik.ai/) es la mejor opción local. Requiere filtros post-procesamiento muy sencillos en Python para ignorar texto conversacional (por ejemplo, *"Aquí está el perfil..."*).
  • Para currículums técnicos en inglés, modelos como Llama 3 (8B) o Mistral dan más estabilidad de formato y consumen menos.

6. Guía paso a paso: Configuración del entorno local

La configuración del sistema requiere cuatro simples pasos. No requiere conocimientos de programación.

Paso 1: Instalar Obsidian

  1. Descargue e instale Obsidian.
  2. Cree una carpeta vacía (por ejemplo, Vault_HR).
  3. Abra Obsidian y elija "Open folder as vault" apuntando a esa carpeta.

Paso 2: Estructura de carpetas

Cree en el Vault:

  1. Carpeta .raw.
  2. Carpeta wiki.
  3. Cree archivos vacíos en wiki: index.md, hot.md y log.md.
  4. Coloque RECRUITER.md en la raíz.

Paso 3: Instalar servidor local (Ollama)

  1. Instale Ollama para su sistema.
  2. Ejecute en consola:

`bash

ollama run bielik

`

  1. La API local se ejecutará en http://localhost:11434.

Paso 4: Integración

  1. En Obsidian: Settings -> Community plugins -> Enable community plugins.
  2. Busque e instale el plugin Copilot.
  3. Cambie el provider a Ollama (http://localhost:11434).
  4. Seleccione el modelo bielik o llama3 y establezca el system prompt a RECRUITER.md.

7. Automatización: Script Python

Para optimizar, usamos un script Python que automatiza la ingesta (lectura offline de PDF, llamada a la API local de Ollama y creación de perfiles).

Requisitos:

pip install pypdf requests

Código del script (ingest_cv.py):

import os
import re
import json
from datetime import datetime
import requests
from pypdf import PdfReader

# --- CONFIGURACION ---
OLLAMA_API_URL = "http://localhost:11434/api/generate"
MODEL_NAME = "bielik"  # Llama3, Mistral etc.
VAULT_PATH = os.path.dirname(os.path.abspath(__file__))
RAW_DIR = os.path.join(VAULT_PATH, ".raw")
WIKI_DIR = os.path.join(VAULT_PATH, "wiki")
SYSTEM_PROMPT_FILE = os.path.join(VAULT_PATH, "RECRUITER.md")

def read_system_prompt():
    """Wczytuje instrukcje z pliku RECRUITER.md"""
    if os.path.exists(SYSTEM_PROMPT_FILE):
        with open(SYSTEM_PROMPT_FILE, "r", encoding="utf-8") as f:
            return f.read()
    return "You are an AI recruiting assistant. Analyze the resume and output structured Markdown."

def extract_text_from_pdf(pdf_path):
    """Ekstrahuje tekst z pliku PDF z CV"""
    reader = PdfReader(pdf_path)
    text = ""
    for page in reader.pages:
        text += page.extract_text() + "\n"
    return text

def analyze_cv_with_local_llm(cv_text, system_prompt):
    """Przesyła tekst CV do lokalnej Ollamy w celu analizy zgodnie z RECRUITER.md"""
    prompt = f"""
Analyze the following raw CV text. 
Create a structured Markdown candidate profile following the exact instructions in the System Prompt.
Output ONLY the Markdown content for the candidate profile. Do not include chat explanations or backticks.

Raw CV Content:
---
{cv_text}
---
"""
    
    payload = {
        "model": MODEL_NAME,
        "prompt": prompt,
        "system": system_prompt,
        "stream": False,
        "options": {
            "temperature": 0.1  # Niska temperatura dla wysokiej precyzji i braku halucynacji
        }
    }
    
    try:
        response = requests.post(OLLAMA_API_URL, json=payload, timeout=180)
        response.raise_for_status()
        return response.json().get("response", "").strip()
    except requests.exceptions.RequestException as e:
        print(f"Błąd komunikacji z lokalną Ollamą: {e}")
        return None

def update_index_and_log(candidate_name, touched_tech):
    """Aktualizuje rejestr wiki/log.md oraz spis treści wiki/index.md"""
    today = datetime.today().strftime('%Y-%m-%d')
    log_file = os.path.join(WIKI_DIR, "log.md")
    index_file = os.path.join(WIKI_DIR, "index.md")
    
    # 1. Aktualizacja log.md
    log_entry = f"- [{today}] Ingested [[Kandydat - {candidate_name}]] -> updated " + ", ".join([f"[[Tech - {t}]]" for t in touched_tech]) + "\n"
    
    existing_log = ""
    if os.path.exists(log_file):
        with open(log_file, "r", encoding="utf-8") as f:
            existing_log = f.read()
            
    with open(log_file, "w", encoding="utf-8") as f:
        f.write(log_entry + existing_log)
        
    # 2. Aktualizacja index.md
    index_content = ""
    if os.path.exists(index_file):
        with open(index_file, "r", encoding="utf-8") as f:
            index_content = f.read()
            
    candidate_link = f"[[Kandydat - {candidate_name}]]"
    if candidate_link not in index_content:
        if "## Kandydaci" in index_content:
            parts = index_content.split("## Kandydaci")
            updated_section = parts[1]
            updated_section = f"\n- {candidate_link} (Ingested: {today})\n" + updated_section
            index_content = parts[0] + "## Kandydaci" + updated_section
        else:
            index_content += f"\n\n## Kandydaci\n- {candidate_link} (Ingested: {today})\n"
            
        with open(index_file, "w", encoding="utf-8") as f:
            f.write(index_content)

def parse_candidate_name_from_markdown(md_content):
    """Próbuje wyciągnąć imię i nazwisko z nagłówka wygenerowanej notatki"""
    match = re.search(r'^# Kandydat -\s*(.*)$', md_content, re.MULTILINE)
    if match:
        return match.group(1).strip()
    
    first_line = md_content.split('\n')[0]
    first_line = re.sub(r'[#\*]', '', first_line).strip()
    if first_line:
        return first_line
    return "Nieznany_Kandydat_" + datetime.now().strftime("%H%M%S")

def main():
    print("=== Uruchamianie lokalnego parsera CV ===")
    system_prompt = read_system_prompt()
    
    if not os.path.exists(RAW_DIR):
        os.makedirs(RAW_DIR)
        print(f"Utworzono folder na wejściowe CV: {RAW_DIR}. Umieść tam pliki PDF.")
        return

    if not os.path.exists(WIKI_DIR):
        os.makedirs(WIKI_DIR)
        
    pdf_files = [f for f in os.listdir(RAW_DIR) if f.lower().endswith('.pdf')]
    
    if not pdf_files:
        print("Brak nowych plików PDF w folderze .raw/")
        return
        
    for pdf_file in pdf_files:
        pdf_path = os.path.join(RAW_DIR, pdf_file)
        print(f"\nPrzetwarzanie pliku: {pdf_file}...")
        
        cv_text = extract_text_from_pdf(pdf_path)
        if not cv_text.strip():
            print(f"Nie udało się odczytać tekstu z {pdf_file}.")
            continue
            
        print("Analiza przez lokalny model LLM (Ollama)...")
        analysis = analyze_cv_with_local_llm(cv_text, system_prompt)
        
        if not analysis:
            print("Analiza nie powiodła się.")
            continue
            
        candidate_name = parse_candidate_name_from_markdown(analysis)
        safe_name = "".join([c for c in candidate_name if c.isalpha() or c.isspace() or c=='-']).strip()
        
        file_name = f"Kandydat - {safe_name}.md"
        output_path = os.path.join(WIKI_DIR, file_name)
        
        if "```markdown" in analysis:
            analysis = analysis.split("```markdown")[1].split("```")[0].strip()
        elif "```" in analysis:
            analysis = analysis.split("```")[1].split("```")[0].strip()
            
        with open(output_path, "w", encoding="utf-8") as f:
            f.write(analysis)
            
        print(f"Zapisano profil kandydata: wiki/{file_name}")
        
        touched_tech = re.findall(r'\[\[Tech - (.*?)\]\]', analysis)
        touched_tech = list(set(touched_tech))
        
        for tech in touched_tech:
            tech_file = os.path.join(WIKI_DIR, f"Tech - {tech}.md")
            if not os.path.exists(tech_file):
                with open(tech_file, "w", encoding="utf-8") as f:
                    f.write(f"# Technologia: {tech}\n\n## Powiązani kandydaci\n- [[Kandydat - {safe_name}]]\n")
            else:
                with open(tech_file, "r+", encoding="utf-8") as f:
                    content = f.read()
                    candidate_link = f"- [[Kandydat - {safe_name}]]"
                    if candidate_link not in content:
                        f.write(f"\n{candidate_link}\n")
        
        update_index_and_log(safe_name, touched_tech)
        
        processed_dir = os.path.join(RAW_DIR, "processed")
        if not os.path.exists(processed_dir):
            os.makedirs(processed_dir)
        os.rename(pdf_path, os.path.join(processed_dir, pdf_file))
        print(f"Przeniesiono oryginalny plik do: .raw/processed/{pdf_file}")

if __name__ == "__main__":
    main()

8. Trabajo diario y casos de uso

Escenario 1: Importar currículums

  1. Se colocan currículums en PDF en .raw/.
  2. Se ejecuta el script ingest_cv.py.
  3. El script usa el modelo local Bielik para generar los archivos Markdown en wiki/ y se actualiza el índice offline.

Escenario 2: Detección de anomalías temporales

El modelo busca incoherencias en el historial y advierte al reclutador (por ejemplo, puestos paralelos presenciales en dos ciudades distintas):

Advertencia cronológica

Candidato declara puestos presenciales paralelos en Company X (Cracovia) desde 01.2024 y Company Y (Varsovia) desde 03.2024. Requiere confirmación por llamada.


9. Resumen

Obsidian y modelos locales demuestran que es posible innovar en recursos humanos manteniendo la privacidad. Ventajas clave: 100% RGPD seguro, sin dependencia de sistemas SaaS comerciales, costes de licencia nulos e inmejorable procesamiento local de polaco e inglés gracias a proyectos como Bielik.ai.

¿Quiere implementar herramientas de IA seguras en su empresa?

Ayudamos a construir sistemas ATS/CRM personalizados, integrar modelos de IA seguros (Ollama, Bielik.ai, LLM locales) y auditar la seguridad de los sistemas de contratación.

Consulte con nosotros