Looking for a way to quickly analyze dozens of applications for a single position, but terrified of fines for violating personal data protection regulations? In this article, we will present a cool tool to support yourself with AI in mass CV analysis without violating GDPR rules. We will show you how to connect the free program Obsidian with local language models (Local LLMs) running directly on your computer or company server.
This way, you will create a private, secure, and fully compliant "second brain" for recruitment, where your data never leaves your local computer.
Table of Contents
- Introduction: AI in Recruitment and the GDPR Trap
- How High Are GDPR Fines in Poland? Real Risk for SMEs
- Obsidian as a Recruitment Knowledge Base (Recruitment Vault)
- Converting the CLAUDE.md template into RECRUITER.md
- Choosing the AI Engine: Llama, Mistral, or Polish Bielik.ai?
- Step-by-Step Guide: Configuring the Local Environment
- Automation: Script Integrating PDF, Ollama, and Obsidian
- Daily Work and Use Cases
- Summary
1. Introduction: AI in Recruitment and the GDPR Trap
The advent of models like GPT-4o or Claude 3.5 Sonnet brought a revolution to HR departments. These tools can read a 10-page resume in seconds, extract key competencies, compare them with technical requirements, and formulate precise questions for the job interview.
However, implementing these tools in the daily work of a recruiter involves serious legal barriers:
- Lack of Data Processing Agreements (DPA): Using free versions of ChatGPT or Claude means accepting the terms of service for individual users. The provider does not guarantee compliance with GDPR, and the recruiting company does not hold a signed data processing agreement.
- Use of Data for Training: Entered prompts and uploaded resume files are used by default to train public models. The personal data of developers (including phone numbers, email addresses, and history of commercial projects protected by NDAs) become part of the global neural network.
- Data Transfer outside the EEA: Data is sent to servers in the US. Without implementing Standard Contractual Clauses (SCCs), this transfer is illegal.
Furthermore, the European Union's AI Act classifies AI systems used in recruitment as high-risk AI systems. This imposes strict technical and legal requirements regarding transparency, cybersecurity, and human supervision (human-in-the-loop – an algorithm cannot autonomously decide to reject a candidate).
2. How High Are GDPR Fines in Poland? Real Risk for SMEs
Unions and legal experts frequently quote maximum fines of up to 20,000,000 EUR or up to 4% of the total annual global turnover of the company. However, it is worth looking at how these regulations are actually interpreted and applied by the Office for Personal Data Protection (UODO) in Poland.
Public Sector vs Private Sector
In Poland, public sector entities (e.g., job offices, municipal institutions) are subject to statutory limits on the amount of fines. According to the Polish Act on the Protection of Personal Data, these fines are limited to a maximum of 100,000 PLN (for state budgetary units) and 10,000 PLN (for municipal cultural institutions). Private enterprises (recruitment agencies, software houses, IT firms) face the full force of European Union GDPR fines.
The Principle of Proportionality and UODO Practice toward SMEs
Does a micro, small, or medium-sized company face a fine of 20 million euros if a recruiter pastes a few resumes into ChatGPT? According to Article 83(1) of the GDPR, the imposed fine must be effective, proportionate, and dissuasive in each individual case. When imposing a fine, UODO considers:
- Scale of the breach: The number of candidates whose data was processed without a DPA.
- Financial capability of the entity: Imposing a fine of several million PLN on a small software house would lead to its bankruptcy, which would violate the principle of proportionality.
- Intentional vs Unintentional action: Was it a one-time mistake by a recruiter or a deliberate company policy forcing mass uploads of candidate data to the public cloud.
- Implemented corrective actions: Did the company immediately block access to public chatbots and implement corrective procedures upon discovering the incident.
What does this mean in practice?
In the case of smaller companies and first-time, unintentional minor violations, UODO most often starts with a warning (upomnienie) coupled with an order to align procedures.
If however a data leak occurs or a candidate files an official complaint, a financial fine is imposed. For small and medium-sized enterprises in Poland, these fines most often range between 5,000 PLN and 50,000 PLN. Although this amount is much lower than the maximum EU thresholds, it is still a painful blow for a small operating budget, especially when combined with the costs of legal audits, lawyers, and reputational damage in the IT sector.
3. Obsidian as a Recruitment Knowledge Base (Recruitment Vault)
Most ATS (Applicant Tracking Systems) are relational databases with a web interface. They store candidate statuses but are poor at building deep context. Searching in them is usually based on simple keywords (e.g., "React", "Kubernetes"), which causes recruiters to lose access to candidates who have the right skills but described them in their resumes in a non-standard way.
Semantic and associative search is the domain of knowledge bases based on a network of connections (graphs). The tool that perfectly realizes this concept on a local disk is Obsidian.
Why Obsidian in recruitment?
- Local Markdown Files: All notes, candidate profiles, and analyses are plain text
.mdfiles stored on the user's hard drive or in the company's secure network share. No SQL database means easy migration, backup, and no dependence on external providers. - Bidirectional Linking (Wikilink): Instead of a rigid directory structure, Obsidian allows connecting concepts using the
syntax. In recruitment, this means a candidate's profile can be linked to technologies, projects, companies, or seniority levels. A dynamic knowledge graph of the talent market is created.[[Page Name]] - Privacy and Offline: Obsidian does not require an internet connection. Candidate data does not leave the secure IT zone of the company.
4. Converting the CLAUDE.md template into RECRUITER.md
In the developer community using AI, the CLAUDE.md template has gained popularity. It is a system instruction file placed in the project's root folder. When an AI agent initializes in that folder, it reads CLAUDE.md to learn the project structure, its rules, and how to modify files.
We adapt this idea for recruitment. We will create a RECRUITER.md file that instructs the local AI assistant how to manage our local recruitment vault.
Folder Structure in Recruitment Vault
In the root directory of our vault, we create the structure:
.raw/– Directory containing raw resume files (PDF, DOCX) or direct text dumps. This directory is strictly read-only for the AI.wiki/– Active HR knowledge base. Here the AI creates and modifies notes on candidates, technologies, and companies.wiki/index.md– Main index (Master Catalog) containing categorized tables of candidates, technologies, and open processes.wiki/hot.md– Context cache. Contains the profile of the ideal candidate for the recruitment process we are currently running.wiki/log.md– Chronological ledger of operations (e.g., when and which resume was imported).
Below is the configuration template for RECRUITER.md. It is written in English because local models interpret system prompts much more precisely in this language.
# 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. Choosing the AI Engine: Llama, Mistral, or Polish Bielik.ai?
To run the system, we need an LLM engine running locally on the computer (on-premise) using software like Ollama or LM Studio. Since we analyze resumes in Polish and English, the model choice is crucial.
Polish Accent: Bielik.ai (https://bielik.ai/)
Bielik is a Polish language model created by the SpeakLeash team and the Academic Computer Center Cyfronet AGH. It is based on the Mistral / Llama architecture and has been intensively trained on massive Polish text datasets.
To install Bielik in Ollama, simply run:
ollama run bielik
Honest Comparison of Models for Recruitment Tasks:
| Model LLM | Polish Language & Local Context | Formatting Precision (JSON/MD) | Logic & Contradiction Detection | Hardware Requirements |
|---|---|---|---|---|
| Bielik (11B-v2) | Outstanding (5/5) Perfectly understands Polish grammar, local universities, companies, and linguistic nuances. |
Good (3.5/5) Can occasionally drift from strict Markdown structure and append short conversational text. |
Medium/Good (3.5/5) Handles basic logic well, but may miss complex chronological anomalies. |
Medium (requires min. 16 GB RAM) |
| Llama 3 / 3.1 (8B) | Medium (3/5) Understands Polish, but generated summaries may contain grammatical errors or direct translations. |
Very Good (4.5/5) Unmatched in strictly following system prompts from RECRUITER.md. |
Good (4/5) Handles data extraction and structuring well. |
Low (requires min. 8 GB RAM) |
| Command R (35B) | Good (4/5) Very grammatically correct, created for advanced retrieval scenarios (RAG). |
Outstanding (5/5) Perfect at creating connected notes, links, and tables. |
Very Good (4.5/5) Excellent at detecting chronological anomalies. |
High (requires min. 32 GB RAM / strong GPU) |
| Flagship Clouds (GPT-4o, Claude 3.5 Sonnet) | Very Good (4.5/5) | Outstanding (5/5) | Outstanding (5/5) Superb at detecting anomalies or timeline inconsistencies. |
None (requires sending candidate data online to APIs) |
Recommending the Right Model:
- If you primarily process resumes in Polish and want summaries written in natural, fluent Polish, Bielik (https://bielik.ai/) is the best local choice. It requires only minor post-processing filters in the Python script to ignore conversational filler (e.g., *"Here is the candidate profile..."*).
- For strictly technical, English-centric CVs, models like Llama 3 (8B) or Mistral provide higher formatting consistency while consuming fewer hardware resources.
6. Step-by-Step Guide: Configuring the Local Environment
Setting up the system requires four simple steps. It does not require programming knowledge and can be completed on Linux, macOS, or Windows.
Step 1: Install Obsidian
- Download and install Obsidian.
- Create a new empty folder on your drive (e.g.,
HR_Vault). - Open Obsidian and choose "Open folder as vault", selecting the newly created directory.
Step 2: Prepare Folder Structure
Inside the HR_Vault folder, create the directory structure:
- Create a
.rawfolder. - Create a
wikifolder. - Inside
wiki, create empty files:index.md,hot.md, andlog.md. - Create the
RECRUITER.mdfile in the root.
Step 3: Install Local AI Server (Ollama)
- Download the installer from the Ollama website for your OS.
- After installation, open a terminal (or PowerShell) and download your model of choice:
`bash
ollama run bielik
`
- Once the download completes, the Ollama API will run in the background under
http://localhost:11434.
Step 4: Integrate with Obsidian
To converse with your local model directly inside Obsidian:
- Go to Settings -> Community plugins -> Enable community plugins.
- Click Browse and search for the Copilot plugin.
- Install and enable the plugin.
- In Copilot settings, change the Provider to Ollama.
- Point to the local API URL (
http://localhost:11434) and choose your model (e.g.,bielikorllama3). - Set the system prompt path to
RECRUITER.md.
7. Automation: Script Integrating PDF, Ollama, and Obsidian
While manual text extraction works, HR teams require automated ingestion. Below is a complete Python script that automates the INGEST workflow.
The script monitors the .raw/ directory, extracts text from new PDF resumes, sends it to the local Ollama API, and writes structured Markdown profiles directly into wiki/, updating the log and index files.
Script Requirements:
Before running, install the required Python packages:
pip install pypdf requests
Script Code (ingest_cv.py):
Save the code below as ingest_cv.py in the root of your Obsidian vault.
import os
import re
import json
from datetime import datetime
import requests
from pypdf import PdfReader
# --- CONFIGURATION ---
OLLAMA_API_URL = "http://localhost:11434/api/generate"
MODEL_NAME = "bielik" # Change to llama3, mistral, etc., if needed
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():
"""Reads instructions from 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):
"""Extracts text from PDF resume"""
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):
"""Sends resume text to local Ollama for structured analysis"""
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 # Low temperature for high precision and no hallucinations
}
}
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"Error communicating with local Ollama: {e}")
return None
def update_index_and_log(candidate_name, touched_tech):
"""Updates wiki/log.md and 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. Update 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. Update 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):
"""Attempts to extract candidate name from Markdown heading"""
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 "Unknown_Candidate_" + datetime.now().strftime("%H%M%S")
def main():
print("=== Running Local CV Parser ===")
system_prompt = read_system_prompt()
if not os.path.exists(RAW_DIR):
os.makedirs(RAW_DIR)
print(f"Created folder for incoming resumes: {RAW_DIR}. Place PDF files there.")
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("No new PDF files found in .raw/")
return
for pdf_file in pdf_files:
pdf_path = os.path.join(RAW_DIR, pdf_file)
print(f"\nProcessing file: {pdf_file}...")
cv_text = extract_text_from_pdf(pdf_path)
if not cv_text.strip():
print(f"Could not extract text from {pdf_file}.")
continue
print("Analyzing with local LLM (Ollama)...")
analysis = analyze_cv_with_local_llm(cv_text, system_prompt)
if not analysis:
print("Analysis failed.")
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"Saved candidate profile: 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"# Technology: {tech}\n\n## Associated Candidates\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"Moved original resume file to: .raw/processed/{pdf_file}")
if __name__ == "__main__":
main()
8. Daily Work and Use Cases
Having this structured vault environment transforms the recruiter workflow.
Scenario 1: Importing Resumes
When a position opens, a batch of 30 PDF resumes arrives.
- The recruiter drops all PDFs into
.raw/. - Runs the
ingest_cv.pyscript. - The script calls the local Bielik model to structure candidate profiles.
- 30 structured candidate files are created under
wiki/, and the index updates automatically.
Scenario 2: Chronological Anomalies (Sanity Check)
The local model checks for timeline anomalies, generating callouts like:
Chronology Warning
Candidate claims full-time employment as a Senior Developer at Company X (Krakow) from 01.2024 - present, and also as a Team Lead at Company Y (Warsaw) from 03.2024 - present. Both are marked as on-site roles. This requires phone screen verification.
9. Summary
Integrating Obsidian with local LLMs shows that HR technology can innovate rapidly without sacrificing privacy compliance. The primary benefits are 100% GDPR safety, no lock-in to proprietary ATS databases, minimal software licensing fees, and outstanding local language support thanks to projects like Bielik.ai.
Want to implement secure AI tools in your company?
We help build custom ATS/CRM systems, integrate secure AI models (Ollama, Bielik.ai, on-premise LLMs), and audit the security of recruitment systems.
Consult with us