Hệ thống tuyển dụng thông minh — ReAct Supervisor điều phối các domain agent (Match / Analyze / Scrape) để hỗ trợ HR và candidate qua chat, hybrid search, và scrape thị trường (TopCV / ITviec).
Multi-Agent Job Search là một nền tảng AI hỗ trợ tuyển dụng đa vai trò:
- 🧑💼 HR: tạo & quản lý JD, xem ứng viên matching theo score, phân tích chi tiết CV vs JD.
- 👤 Candidate: upload CV, hỏi đáp về độ phù hợp, tìm việc trong kho nội bộ hoặc cào tự động từ TopCV / ITviec.
- 🤖 Chatbot: một ReAct Supervisor (LangGraph) tự chọn tool phù hợp (search / match / analyze / scrape) thay vì hardcode if/else.
- 📊 Observability: trace mọi LLM call + tool call qua Langfuse (session_id, user_id, token usage).
- ⏰ Cron: cào thị trường định kỳ 6h/lần cho 8 từ khoá hot.
| Nhóm | Chức năng |
|---|---|
| 👤 Auth | Đăng ký / đăng nhập, phân quyền HR/candidate, bcrypt password |
| 📝 JD | CRUD JD, parse JD (LLM + regex fallback), 6-chunk embedding profile/skills/requirements/responsibilities/benefits |
| 📄 CV | Upload PDF, OCR (Tesseract) + LLM cleanup, parse có cấu trúc, embed multi-field |
| 🔎 Matching | Hybrid search (dense BGE-M3 + sparse BM25) qua Qdrant RRF fusion |
| 🧠 Analysis | Claude Sonnet 4.6 sinh fit_summary, strengths/weaknesses, missing_skills, recommendation |
| 💬 Chat | ReAct Supervisor + 4 tool, memory đa lượt (Postgres) |
| 🕷️ Scraper | Tavily search TopCV + ITviec, dedupe URL, rate-limit 60s, guardrail title + skill, async job queue |
| ⏰ Cron | Cào 8 top keywords / 6h, idempotent, langfuse trace |
| 📡 Observability | Langfuse v4 — generation/agent/tool spans, session_id, user_id, token usage |
| Layer | Công nghệ |
|---|---|
| 🎨 Frontend | React 18, Vite, Pure CSS |
| ⚙️ Backend | FastAPI, Pydantic v2, SQLAlchemy 2, Uvicorn |
| 🤖 Orchestrator | LangGraph prebuilt ReAct agent, LangChain Core, LangChain OpenAI |
| 🧠 LLM | Claude Opus / Sonnet 4.6 (heavy), GPT-5.5 (light), proxy qua 9router (OpenAI-compat) |
| 🧬 Embedding | BAAI/bge-m3 (dense 1024d), Qdrant/bm25 (sparse) |
| 🧠 Vector DB | Qdrant — named dense + sparse vectors, hybrid RRF |
| 🗄️ RDBMS | PostgreSQL 16 (users, sessions, conversations, messages, applications) |
| 🕷️ Scraper | Tavily Search API (TopCV, ITviec) |
| 📡 Tracing | Langfuse v4 (OTEL-based) |
| 📄 PDF/OCR | PyMuPDF, Tesseract |
| 📦 DevOps | Docker, Docker Compose 4 service + 1 cron sidecar |
graph TB
subgraph Client["🌐 Client"]
UI[React + Vite UI]
end
subgraph FastAPI["⚙️ FastAPI Backend"]
direction TB
ROUTES[Routes: auth · cv · jd · chat]
CHAT[ChatService]
SUPER["🤖 ReAct Supervisor (LangGraph)"]
TOOLS["🛠️ Tools: search_internal · match · analyze · scrape"]
AGENTS["Domain Agents: Matching · Analysis · Scraper"]
ROUTES --> CHAT --> SUPER --> TOOLS --> AGENTS
end
subgraph Storage["💾 Storage"]
QD[(Qdrant<br/>JD/CV chunks)]
PG[(PostgreSQL<br/>users + chat memory)]
FS[/uploads /<br/>PDF files/]
end
subgraph External["☁️ External"]
LLM[9router / Claude Sonnet 4.6]
TAV[Tavily Search API]
LF[Langfuse Cloud]
end
subgraph Bg["⏰ Background"]
CRON["Cron sidecar<br/>(6h interval)"]
end
UI -->|HTTP| ROUTES
AGENTS -->|embed + search| QD
ROUTES -->|session/auth/memory| PG
ROUTES -.->|CV PDF| FS
AGENTS -->|prompt| LLM
AGENTS -->|scrape| TAV
SUPER -.->|trace + spans| LF
CRON -->|scrape_and_index| AGENTS
sequenceDiagram
participant U as User
participant API as /chat/{id}/messages
participant SUP as Supervisor
participant LLM as Claude Sonnet
participant T as Tool
participant Q as Qdrant
participant LF as Langfuse
U->>API: POST { message, jd_id?, cv_id? }
API->>SUP: invoke(conversation, message)
activate SUP
SUP->>LF: start span "chat.message" (session_id, user_id)
SUP->>LLM: system prompt + history + user msg
LLM-->>SUP: tool_call (vd: search_internal_jd)
SUP->>T: execute tool
T->>Q: hybrid_search / scroll
Q-->>T: chunks
T-->>SUP: tool result
SUP->>LLM: tool result → final reasoning
LLM-->>SUP: reply text
SUP-->>API: ChatResponse
SUP->>LF: flush trace
deactivate SUP
API-->>U: { reply, tool_calls }
sequenceDiagram
participant U as User
participant API as /jd/scrape
participant BG as BackgroundTask
participant SC as JobScraperAgent
participant TAV as Tavily
participant Q as Qdrant
U->>API: POST { query, sources, limit }
API->>BG: enqueue job_id
API-->>U: { job_id, status:"queued" }
activate BG
BG->>SC: scrape_and_index()
SC->>SC: rate-limit check (60s/query)
SC->>TAV: search_jobs
TAV-->>SC: [URLs + snippets]
loop mỗi URL
SC->>Q: dedupe by url_hash
SC->>TAV: extract_content(url)
SC->>SC: parse JD + guardrail (skills + title)
SC->>Q: embed + upsert
end
BG-->>API: mark_done(result)
deactivate BG
U->>API: GET /jd/scrape/{job_id} (poll mỗi 3s)
API-->>U: { status:"done", result:{ scraped, indexed, dup, invalid } }
flowchart LR
A[Upload CV PDF] --> B[PyMuPDF + Tesseract OCR]
B --> C[LLM cleanup OCR<br/>nếu confidence < 85]
C --> D[Parse CV: summary, skills, exp, projects]
D --> E[Multi-field embedding]
E --> F[(Qdrant: dense + sparse)]
G[HR chọn JD] --> H[MatchingAgent]
F --> H
H --> I[Hybrid search RRF]
I --> J[Top-K candidates + scores]
J --> K[AnalysisAgent → Claude Sonnet]
K --> L[fit_summary + strengths + missing_skills]
multi-agent-job-search/
├── backend/
│ ├── agents/ # Domain agents + supervisor
│ │ ├── supervisor.py # ReAct orchestrator (LangGraph prebuilt)
│ │ ├── tools.py # StructuredTool factories
│ │ ├── analysis_agent.py # CV vs JD chi tiết
│ │ ├── matching_agent.py # JD → top candidates
│ │ ├── scraper_agent.py # Tavily scrape + dedupe + rate-limit
│ │ └── schemas.py # Pydantic schemas (args + results)
│ ├── database/
│ │ ├── db.py # SQLAlchemy engine + session
│ │ ├── models.py # User, Session, Conversation, Message, Application
│ │ └── init_db.py
│ ├── routes/
│ │ ├── auth_route.py # /auth/register, /login, /me
│ │ ├── cv_route.py # /cv/upload, /evaluate, /apply
│ │ ├── jd_route.py # /jd/create, /update, /scrape, /match
│ │ └── chat_route.py # /chat/conversations, /messages
│ ├── services/
│ │ ├── llm/ # 9router OpenAI-compat client
│ │ │ ├── client.py # call_llm, call_llm_json, retry, usage capture
│ │ │ ├── prompts.py # 5 prompt templates
│ │ │ └── tasks.py # task wrappers (analyze, extract, chat)
│ │ ├── observability/ # Langfuse v4 wrapper (no-op nếu thiếu key)
│ │ ├── scraper/
│ │ │ ├── tavily_service.py # TavilyClient wrap + lru cache
│ │ │ └── scrape_jobs.py # in-memory job status tracking
│ │ ├── chat/ # memory_service, schemas, chat_service
│ │ ├── cv/ # PDF extractor, OCR, regex parser
│ │ ├── jd/ # JD service, regex parser
│ │ ├── auth_service.py
│ │ ├── embedding_service.py # BGE-M3 + BM25
│ │ └── qdrant_service.py # Hybrid search RRF
│ ├── Dockerfile
│ └── main.py # FastAPI lifespan + wire services
├── frontend/
│ ├── src/
│ │ ├── main.jsx
│ │ └── styles.css
│ ├── Dockerfile
│ ├── nginx.conf
│ └── package.json
├── scripts/
│ └── cron_crawl.py # Crawl 8 top keyword × 5 JD / 6h
├── docs/ # Phase notes
├── uploads/ # CV PDFs (gitignored)
├── docker-compose.yml # qdrant + postgres + backend + frontend + cron
├── requirements.txt
└── .env # gitignored
- 🐳 Docker Desktop (Windows / macOS) hoặc Docker Engine + Compose plugin (Linux). Tối thiểu 6GB RAM cho container backend (embed models lớn).
- 🟢 Node.js 20+ (chỉ cần nếu chạy frontend ngoài Docker).
- 🐍 Python 3.10+ (chỉ cần nếu chạy backend ngoài Docker).
- 🔑 API keys:
- 9router / LLM provider (Claude / GPT-5.5 qua proxy OpenAI-compat).
- Tavily (tavily.com, free 1000 req/tháng).
- Langfuse (cloud.langfuse.com, optional — bỏ trống nếu không cần trace).
Tạo file .env ở root (nếu chưa có):
# === Database ===
DATABASE_URL=postgresql://jobagent:jobagent123@localhost:5432/jobagent
# === LLM (9router OpenAI-compat) ===
LLM_PROVIDER=9router
LLM_BASE_URL=http://host.docker.internal:20128/v1/chat/completions
LLM_API_KEY=sk-...
# Per-task model overrides
LLM_MODEL_SUPERVISOR=cc/claude-sonnet-4-6
LLM_MODEL_CV_PARSE=cc/claude-sonnet-4-6
LLM_MODEL_JD_PARSE=cc/claude-sonnet-4-6
LLM_MODEL_CV_JD_ANALYSIS=cc/claude-sonnet-4-6
LLM_MODEL_GENERAL_CHAT=cc/claude-sonnet-4-6
LLM_MODEL_INTENT=cx/gpt-5.5
LLM_MODEL_OCR_CLEANUP=cx/gpt-5.5
# === Tavily (scrape thị trường) ===
TAVILY_API_KEY=tvly-...
SCRAPER_MAX_RESULTS=10
SCRAPER_INCLUDE_DOMAINS=topcv.vn,itviec.com
# === Langfuse (optional - tracing) ===
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_HOST=https://cloud.langfuse.com
⚠️ Không commit.env— đã có trong.gitignore.
# 1. Clone
git clone <repo-url>
cd multi-agent-job-search
# 2. Tạo .env theo template ở trên
notepad .env
# 3. Build + up tất cả service (lần đầu ~10-15 phút do tải embedding model)
docker compose up -d --build
# 4. Theo dõi log backend đến khi "Application startup complete"
docker compose logs -f backendgit clone <repo-url>
cd multi-agent-job-search
cp .env.example .env # hoặc tạo mới theo template
nano .env # điền API keys
docker compose up -d --build
docker compose logs -f backend| Service | URL | Note |
|---|---|---|
| 🎨 Frontend | http://localhost:5173 | React UI |
| ⚙️ Backend | http://localhost:8000 | FastAPI + auto docs /docs |
| 🧬 Qdrant | http://localhost:6333/dashboard | Vector DB UI |
| 🗄️ PostgreSQL | localhost:5432 | User: jobagent / Pass: jobagent123 |
| ⏰ Cron | (background) | docker compose logs cron |
docker compose ps # check status
docker compose logs -f backend # tail backend log
docker compose restart backend # restart 1 service
docker compose run --rm cron python -m scripts.cron_crawl # trigger crawl ngay
docker compose down # stop all
docker compose down -v # stop + xoá volume (mất dữ liệu)Windows (PowerShell):
python -m venv venv
.\venv\Scripts\Activate.ps1
pip install -r requirements.txt
# Cần Qdrant + Postgres chạy sẵn
docker compose up -d qdrant postgres
uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000macOS / Linux:
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
docker compose up -d qdrant postgres
uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000Backend cần dùng
DATABASE_URL=postgresql://jobagent:jobagent123@localhost:5432/jobagent(đổipostgres→localhostkhi không chạy trong Docker network).
cd frontend
npm install
npm run dev# Trong venv backend
python -m scripts.cron_crawl- 🌐 Mở http://localhost:5173.
- 🔐 Register tài khoản → chọn role HR hoặc Candidate.
- 🧑💼 HR: tạo JD qua form đầy đủ (title, skills, salary, mô tả).
- 📄 Candidate: chọn 1 JD → upload CV → bấm Apply.
- 🎯 HR: vào JD → bấm Matching để xem top candidates theo score.
- 💬 Mở chat — hỏi bất kỳ: "JD này phù hợp với CV nào?", "Top 5 ứng viên?", "Có vị trí Python Senior nào ở HN?" — Supervisor tự gọi tool.
- 🕷️ Candidate: search "VPN Engineer" → rỗng → bấm "Tìm trên thị trường" → Tavily cào TopCV/ITviec → JD mới xuất hiện sau ~30s.
- 📊 Mở cloud.langfuse.com → xem trace mỗi request + token cost.
# Login
TOKEN=$(curl -s -X POST http://localhost:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"<email>","password":"<pwd>"}' | jq -r .data.access_token)
# Submit scrape job
JOB=$(curl -s -X POST http://localhost:8000/jd/scrape \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"Senior Python Developer Ha Noi","limit":5}' | jq -r .data.job_id)
# Poll status
curl -s http://localhost:8000/jd/scrape/$JOB \
-H "Authorization: Bearer $TOKEN" | jqExpected: status queued → running → done sau ~20s với result.indexed > 0.
Re-submit cùng query trong 60s → error: "rate_limited: retry sau Xs" (rate-limit hoạt động).
| Symptom | Fix |
|---|---|
503 Tavily chưa cấu hình |
Set TAVILY_API_KEY trong .env → docker compose restart backend |
[Langfuse] init failed |
Sai key. Để trống cả 2 keys nếu không cần — sẽ silent skip |
Backend không start, log host.docker.internal: Name or service not known (Linux) |
Linux không có host.docker.internal. Đổi LLM_BASE_URL thành IP host hoặc thêm extra_hosts: ["host.docker.internal:host-gateway"] trong docker-compose backend |
Embedding chậm lần đầu (Downloading...) |
Model BGE-M3 ~2GB. Lần đầu ~3-5 phút |
port 5432 already in use |
Postgres host đang chạy. Đổi ports: ["5433:5432"] trong docker-compose |
| Cron không log gì | docker compose logs cron — đợi cron sleep cycle hoặc chạy thủ công docker compose run --rm cron python -m scripts.cron_crawl |
- Ownership: JD external từ scraper có
hr_id = null+external = true. Candidate xem + link sang trang gốc, không ứng tuyển qua hệ thống. - Privacy: Parser CV/JD KHÔNG trích xuất PII (email, phone, address) — chỉ giữ professional content.
- Cost: LLM Sonnet 4.6 cho tool reasoning, GPT-5.5 cho task nhẹ (intent / OCR cleanup) — tối ưu chi phí.
- Rollback: Phase B/C đã verify live, không còn fallback workflow cũ. Để revert dùng git tag/branch.
React UI
↓ HTTP
FastAPI + ReAct Supervisor (LangGraph)
├→ Tools → Domain Agents (Match · Analyze · Scrape · Search)
│ ↓
├→ Qdrant (hybrid search BGE-M3 + BM25)
├→ PostgreSQL (auth + chat memory)
├→ Tavily (TopCV + ITviec scraping)
├→ 9router / Claude Sonnet (reasoning)
└→ Langfuse (observability)
Một câu hỏi user → Supervisor tự decide → tool tự execute → reply có data thật, mọi step được trace.