Spaces:
Sleeping
Sleeping
| from fastapi import FastAPI, UploadFile, File, Form | |
| from fastapi.responses import HTMLResponse | |
| from fastapi.responses import JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pymongo import MongoClient | |
| from langchain_community.embeddings import HuggingFaceEmbeddings | |
| from langchain_community.vectorstores import MongoDBAtlasVectorSearch | |
| from pypdf import PdfReader | |
| from collections import defaultdict | |
| import google.generativeai as genai | |
| import io, traceback, numpy as np | |
| import os | |
| from fastapi import Request | |
| GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") | |
| MONGO_URI = "mongodb+srv://trang_topcv:ai.rxhfZ7h4z.TK@trangtopcv.74csrl4.mongodb.net/?retryWrites=true&w=majority" | |
| DB_NAME = "topcv_jobs" | |
| COLLECTION_NAME = "job" | |
| INDEX_NAME = "job_index" | |
| genai.configure(api_key=GEMINI_API_KEY) | |
| # INIT | |
| client = MongoClient(MONGO_URI) | |
| collection = client[DB_NAME][COLLECTION_NAME] | |
| embeddings = HuggingFaceEmbeddings( | |
| model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" | |
| ) | |
| vectorstore = MongoDBAtlasVectorSearch( | |
| collection=collection, | |
| embedding=embeddings, | |
| index_name=INDEX_NAME, | |
| text_key="chunk", | |
| embedding_key="embedding" | |
| ) | |
| # UTILS | |
| def extract_text_from_pdf(file_bytes: bytes) -> str: | |
| """Đọc nội dung PDF""" | |
| reader = PdfReader(io.BytesIO(file_bytes)) | |
| text = "" | |
| for page in reader.pages: | |
| page_text = page.extract_text() or "" | |
| text += page_text + "\n" | |
| if not text.strip(): | |
| raise ValueError("Không trích xuất được nội dung từ PDF.") | |
| return text.strip() | |
| def chunk_text(text, chunk_size=500, overlap_size=50): | |
| """Chia CV thành các đoạn nhỏ để embedding""" | |
| chunks, start = [], 0 | |
| while start < len(text): | |
| end = min(start + chunk_size, len(text)) | |
| chunks.append(text[start:end]) | |
| start += chunk_size - overlap_size | |
| return chunks | |
| def aggregate_chunks(docs): | |
| """Gom các đoạn chunk theo Job_ids""" | |
| job_scores = defaultdict(list) | |
| job_metadata = dict() | |
| for doc in docs: | |
| meta = doc.metadata.get("metadata", doc.metadata) | |
| job_id = meta.get("Job_ids") | |
| if not job_id: | |
| continue | |
| score = getattr(doc, "score", 1) | |
| job_scores[job_id].append(score) | |
| if job_id not in job_metadata: | |
| job_metadata[job_id] = meta | |
| job_final = [] | |
| for job_id, scores in job_scores.items(): | |
| avg_score = sum(scores) / len(scores) | |
| job_final.append({ | |
| "job_id": job_id, | |
| "score": avg_score, | |
| "metadata": job_metadata[job_id] | |
| }) | |
| job_final_sorted = sorted(job_final, key=lambda x: x["score"], reverse=True)[:5] | |
| return job_final_sorted | |
| def format_job_markdown(md: dict) -> str: | |
| """Hiển thị job dạng Markdown có link click được""" | |
| link = md.get("Link công việc", "") | |
| link_md = f"[🔗 Xem chi tiết tại đây]({link})" if link else "Không có link" | |
| return ( | |
| f"**💼 Vị trí:** {md.get('Vị trí','')}\n" | |
| f"**🏢 Công ty:** {md.get('Công ty','')}\n" | |
| f"**💰 Mức lương:** {md.get('Mức lương','')}\n" | |
| f"**📍 Địa điểm:** {md.get('Địa điểm','')}\n" | |
| f"**📝 Mô tả:** {md.get('Mô tả công việc','')[:400]}...\n" | |
| f"**🎯 Yêu cầu:** {md.get('Yêu cầu ứng viên','')}\n" | |
| f"{link_md}\n\n" | |
| ) | |
| # FASTAPI APP | |
| app = FastAPI(title="Chatbot Tư vấn Việc làm IT - TopCV") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| user_context = {} | |
| # Upload CV + gợi ý job | |
| async def upload_cv(request: Request, file: UploadFile = File(...)): | |
| diagnostics = {} | |
| try: | |
| # 0. đọc file | |
| file_bytes = await file.read() | |
| cv_text = extract_text_from_pdf(file_bytes) | |
| diagnostics["cv_length"] = len(cv_text) | |
| diagnostics["cv_sample"] = cv_text[:300].replace("\n", " ") | |
| if not cv_text.strip(): | |
| return JSONResponse({"status": "error", "message": "Không trích xuất được nội dung từ CV.", "diagnostics": diagnostics}, status_code=400) | |
| # 1. chunk CV | |
| chunks = chunk_text(cv_text) | |
| diagnostics["num_chunks"] = len(chunks) | |
| if len(chunks) == 0: | |
| return JSONResponse({"status": "error", "message": "CV quá ngắn sau khi chunk.", "diagnostics": diagnostics}, status_code=400) | |
| # 2. kiểm tra DB có embedding sample không | |
| sample_doc = collection.find_one({"embedding": {"$exists": True}}, {"embedding": 1}) | |
| if not sample_doc or "embedding" not in sample_doc: | |
| return JSONResponse({ | |
| "status": "error", | |
| "message": "Không tìm thấy trường 'embedding' trong MongoDB. Hãy đảm bảo bạn đã lưu embedding cho job.", | |
| "diagnostics": diagnostics | |
| }, status_code=400) | |
| db_emb = sample_doc["embedding"] | |
| diagnostics["db_embedding_dim"] = len(db_emb) | |
| all_docs = [] | |
| # 3. xử lý từng chunk: build embedding + tìm kiếm | |
| for idx, ch in enumerate(chunks): | |
| try: | |
| emb = embeddings.embed_query(ch) | |
| # convert nếu trả về numpy array | |
| if hasattr(emb, "tolist"): | |
| emb_list = emb.tolist() | |
| else: | |
| # đảm bảo là list of float | |
| emb_list = list(map(float, emb)) | |
| # lưu diagnostics cho chunk đầu | |
| if idx == 0: | |
| diagnostics["first_chunk_sample"] = ch[:200].replace("\n", " ") | |
| diagnostics["first_chunk_emb_dim"] = len(emb_list) | |
| diagnostics["first_chunk_emb_head"] = emb_list[:5] | |
| # nếu dimension khác, cố fallback sang text-search | |
| docs = [] | |
| if len(emb_list) == diagnostics["db_embedding_dim"]: | |
| try: | |
| docs = vectorstore.similarity_search_by_vector(emb_list, k=8) | |
| diagnostics.setdefault("vector_search_counts", []).append(len(docs)) | |
| except Exception as e: | |
| diagnostics.setdefault("vector_search_errors", []).append(str(e)) | |
| docs = [] | |
| else: | |
| diagnostics.setdefault("dim_mismatch_chunks", []).append({ | |
| "chunk_idx": idx, | |
| "chunk_dim": len(emb_list), | |
| "db_dim": diagnostics["db_embedding_dim"] | |
| }) | |
| # fallback: nếu vector search rỗng, thử text search (vectorstore tự embed bên trong) | |
| if not docs: | |
| try: | |
| docs_text = vectorstore.similarity_search(ch, k=8) | |
| diagnostics.setdefault("text_search_counts", []).append(len(docs_text)) | |
| docs = docs_text | |
| except Exception as e: | |
| diagnostics.setdefault("text_search_errors", []).append(str(e)) | |
| docs = [] | |
| all_docs.extend(docs) | |
| except Exception as e: | |
| diagnostics.setdefault("chunk_embedding_errors", []).append({"idx": idx, "error": str(e)}) | |
| continue | |
| # 4. Nếu không tìm thấy bất kỳ doc nào | |
| diagnostics["total_docs_found"] = len(all_docs) | |
| if not all_docs: | |
| return JSONResponse({ | |
| "status": "error", | |
| "message": "Không tìm được công việc tương tự trong cơ sở dữ liệu. Kiểm tra embedding hoặc MongoDB index.", | |
| "diagnostics": diagnostics | |
| }, status_code=200) | |
| # 5. Gom theo job_id và chọn top jobs | |
| top_jobs = aggregate_chunks(all_docs) | |
| diagnostics["top_jobs_count"] = len(top_jobs) | |
| if not top_jobs: | |
| return JSONResponse({ | |
| "status": "error", | |
| "message": "Không tìm thấy job phù hợp sau khi gom chunk.", | |
| "diagnostics": diagnostics | |
| }, status_code=200) | |
| # 6. format job snippets (markdown clickable links) | |
| job_snippets = "\n".join([format_job_markdown(j["metadata"]) for j in top_jobs]) | |
| # 7. gọi Gemini LLM | |
| # 7. Gọi Gemini LLM để sinh gợi ý công việc | |
| diagnostics = {} | |
| try: | |
| model = genai.GenerativeModel("gemini-2.0-flash-001") | |
| short_cv = cv_text[:1500] # tránh lỗi do CV quá dài | |
| top_jobs_text = "\n".join([format_job_markdown(j["metadata"]) for j in top_jobs[:5]]) | |
| prompt = f""" | |
| Dưới đây là nội dung CV và danh sách các công việc có thể phù hợp. | |
| ### 🧾 CV Ứng viên: | |
| {short_cv} | |
| ### 💼 Danh sách công việc tương tự: | |
| {top_jobs_text} | |
| Hãy gợi ý 3-5 công việc phù hợp nhất (chọn trong danh sách trên) | |
| và nêu ngắn gọn lý do phù hợp cho từng công việc. | |
| """ | |
| response = model.generate_content(prompt) | |
| if not hasattr(response, "text") or not response.text: | |
| summary = "Không có phản hồi từ Gemini." | |
| else: | |
| summary = response.text.strip() | |
| except Exception as e: | |
| diagnostics["gemini_error"] = str(e) | |
| print("⚠️ Lỗi khi gọi Gemini:", traceback.format_exc()) | |
| summary = f"Lỗi khi gọi Gemini: {str(e)}" | |
| # 8. Lưu context vào request.state (không dùng global nữa) | |
| request.state.last_cv_jobs = top_jobs_text | |
| request.state.last_cv_summary = summary | |
| return JSONResponse({ | |
| "status": "success", | |
| "suggestions": summary, | |
| "jobs": top_jobs_text, | |
| "diagnostics": diagnostics | |
| }) | |
| except Exception as e: | |
| diagnostics["exception"] = str(e) | |
| print("❌ Lỗi upload_cv:", traceback.format_exc()) | |
| return JSONResponse({ | |
| "status": "error", | |
| "message": str(e), | |
| "diagnostics": diagnostics | |
| }, status_code=500) | |
| # Chat query (dùng hoặc không dùng CV) | |
| async def chat(request: Request, query: str = Form(...)): | |
| try: | |
| # Lấy context từ request.state | |
| cv_summary = getattr(request.state, "last_cv_summary", "") | |
| job_snippets = getattr(request.state, "last_cv_jobs", "") | |
| # Nếu chưa có CV, tìm trực tiếp theo query | |
| if not cv_summary: | |
| docs = vectorstore.similarity_search(query, k=10) | |
| top_jobs = aggregate_chunks(docs) | |
| job_snippets = "\n".join([format_job_markdown(j["metadata"]) for j in top_jobs]) | |
| cv_summary = "Người dùng chưa upload CV, dùng kết quả tìm kiếm trực tiếp." | |
| prompt = f""" | |
| Dữ liệu tham khảo: | |
| {cv_summary} | |
| Các công việc liên quan: | |
| {job_snippets} | |
| Người dùng hỏi: | |
| {query} | |
| Hãy trả lời dựa trên dữ liệu ở trên. | |
| Nếu không đủ thông tin, trả lời: | |
| "Tôi không có thông tin chi tiết về công việc này." | |
| """ | |
| model = genai.GenerativeModel("gemini-2.0-flash-001") | |
| response = model.generate_content(prompt) | |
| return {"query": query, "response": response.text.strip()} | |
| except Exception as e: | |
| print("❌ Lỗi chat:", traceback.format_exc()) | |
| return JSONResponse({"status": "error", "message": str(e)}, status_code=400) | |
| def home(): | |
| return HTMLResponse(content=""" | |
| <!DOCTYPE html> | |
| <html lang="vi"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Chatbot Tư vấn Việc làm IT</title> | |
| <style> | |
| body { | |
| font-family: "Inter", sans-serif; | |
| background: linear-gradient(135deg, #ffe6f0, #f5f0ff); | |
| display: flex; | |
| justify-content: center; | |
| padding: 24px; | |
| } | |
| #container { | |
| width: 720px; | |
| background: #fff9fb; | |
| border-radius: 10px; | |
| box-shadow: 0 6px 18px rgba(255, 182, 193, 0.25); | |
| padding: 18px; | |
| } | |
| #chatbox { | |
| height: 420px; | |
| overflow: auto; | |
| border: 1px solid #f4d7e3; | |
| padding: 12px; | |
| border-radius: 8px; | |
| background: #fffafc; | |
| } | |
| .msg { | |
| margin: 10px 0; | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 8px; | |
| } | |
| .user { | |
| flex-direction: row-reverse; | |
| text-align: right; | |
| } | |
| .msg .avatar { | |
| width: 34px; | |
| height: 34px; | |
| border-radius: 50%; | |
| box-shadow: 0 0 4px rgba(0,0,0,0.1); | |
| } | |
| .msg .text { | |
| padding: 10px 14px; | |
| border-radius: 14px; | |
| line-height: 1.5; | |
| max-width: 75%; | |
| white-space: pre-wrap; | |
| } | |
| /* 🎀 Bong bóng chat pastel */ | |
| .user .text { | |
| --user-bg: #e3f2fd; | |
| background: var(--user-bg); | |
| border: 1px solid #cdddfd; | |
| border-radius: 14px 14px 0 14px; | |
| } | |
| .bot .text { | |
| --bot-bg: #fce4ec; | |
| background: var(--bot-bg); | |
| border: 1px solid #f5c6d0; | |
| border-radius: 14px 14px 14px 0; | |
| } | |
| .avatar { | |
| width: 28px; | |
| height: 28px; | |
| border-radius: 50%; | |
| } | |
| #controls { | |
| margin-top: 12px; | |
| display: flex; | |
| gap: 8px; | |
| } | |
| #text { | |
| flex: 1; | |
| padding: 8px 10px; | |
| border: 1px solid #f7d1e1; | |
| border-radius: 6px; | |
| background: #fffafb; | |
| } | |
| button { | |
| padding: 8px 14px; | |
| border-radius: 6px; | |
| border: none; | |
| background: #ffb6c1; | |
| color: #fff; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: 0.3s; | |
| } | |
| button:hover { | |
| background: #ff9db3; | |
| } | |
| #upload { | |
| margin-top: 12px; | |
| border: 2px dashed #f5c6d0; | |
| padding: 18px; | |
| text-align: center; | |
| border-radius: 8px; | |
| background: #fff0f5; | |
| cursor: pointer; | |
| color: #444; | |
| transition: 0.3s; | |
| } | |
| #upload.drag { | |
| background: #ffe4ec; | |
| border-color: #ffb6c1; | |
| } | |
| .thinking { | |
| font-style: italic; | |
| color: #888; | |
| animation: blink 1.2s infinite; | |
| } | |
| @keyframes blink { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.4; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="container"> | |
| <h1>🤖 Chatbot Tư vấn Việc làm IT</h1> | |
| <div id="chatbox"></div> | |
| <div id="controls"> | |
| <input id="text" placeholder="Nhập câu hỏi..." onkeypress="checkEnter(event)" /> | |
| <button onclick="send()">Gửi</button> | |
| </div> | |
| <div id="upload" ondrop="drop(event)" ondragover="allowDrop(event)"> | |
| 📄 Kéo & thả file CV (PDF) vào đây để bot gợi ý việc làm phù hợp | |
| </div> | |
| </div> | |
| <script> | |
| const chatbox = document.getElementById('chatbox'); | |
| const botAvatar = "https://cdn-icons-png.flaticon.com/512/4712/4712100.png"; | |
| const userAvatar = "https://cdn-icons-png.flaticon.com/512/1077/1077012.png"; | |
| function appendMsg(sender, text, avatar, extraClass = "") { | |
| const d = document.createElement('div'); | |
| d.className = 'msg ' + sender; | |
| const img = document.createElement('img'); | |
| img.src = avatar; | |
| img.className = 'avatar'; | |
| const span = document.createElement('div'); | |
| span.className = 'text ' + extraClass; | |
| span.innerHTML = makeLinksClickable(text); | |
| d.appendChild(img); | |
| d.appendChild(span); | |
| chatbox.appendChild(d); | |
| chatbox.scrollTop = chatbox.scrollHeight; | |
| return d; | |
| } | |
| function cleanMarkdown(str) { | |
| return str.replace(/\*\*/g, '').replace(/\*/g, '').replace(/`/g, '').replace(/_/g, '').replace(/#+/g, '').replace(/\>/g, ''); | |
| } | |
| async function send() { | |
| const txt = document.getElementById('text').value.trim(); | |
| if (!txt) return; | |
| appendMsg('user', txt, userAvatar); | |
| document.getElementById('text').value = ''; | |
| const fd = new FormData(); | |
| fd.append('query', txt); | |
| const thinkingMsg = appendMsg('bot', '🤖 Đang suy nghĩ...', botAvatar, 'thinking'); | |
| try { | |
| const res = await fetch('/chat', { method: 'POST', body: fd }); | |
| let j; | |
| try { | |
| j = await res.json(); | |
| } catch { | |
| j = { message: 'Server trả về không phải JSON' }; | |
| } | |
| chatbox.removeChild(thinkingMsg); | |
| const text = j.response || j.message || 'Đã có lỗi xảy ra'; | |
| appendMsg('bot', cleanMarkdown(text), botAvatar); | |
| } catch (e) { | |
| chatbox.removeChild(thinkingMsg); | |
| appendMsg('bot', 'Lỗi kết nối: ' + e.message, botAvatar); | |
| } | |
| } | |
| function allowDrop(e) { e.preventDefault(); } | |
| async function drop(e) { | |
| e.preventDefault(); | |
| const f = e.dataTransfer.files[0]; | |
| if (!f) return; | |
| appendMsg('user', '📎 Đã tải lên: ' + f.name, userAvatar); | |
| const fd = new FormData(); | |
| fd.append('file', f); | |
| const thinkingMsg = appendMsg('bot', '🤖 Đang xử lý CV...', botAvatar, 'thinking'); | |
| try { | |
| const res = await fetch('/upload_cv', { method: 'POST', body: fd }); | |
| let j; | |
| try { | |
| j = await res.json(); | |
| } catch { | |
| j = { message: 'Server trả về không phải JSON' }; | |
| } | |
| chatbox.removeChild(thinkingMsg); | |
| const text = j.suggestions || j.message || 'Đã có lỗi xảy ra'; | |
| appendMsg('bot', cleanMarkdown(text), botAvatar); | |
| } catch (err) { | |
| chatbox.removeChild(thinkingMsg); | |
| appendMsg('bot', 'Lỗi upload: ' + err.message, botAvatar); | |
| } | |
| } | |
| function checkEnter(e) { | |
| if (e.key === "Enter") { | |
| e.preventDefault(); // chặn xuống dòng | |
| send(); // gọi hàm gửi tin | |
| } | |
| } | |
| function makeLinksClickable(str) { | |
| const urlRegex = /(https?:\/\/[^\s]+)/g; | |
| return str.replace(urlRegex, (url) => { | |
| return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`; | |
| }); | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| """) | |
| def status(): | |
| return {"message": "Chatbot Tư vấn Việc làm IT đang chạy!"} | |