ViniciusKhan commited on
Commit
f2d72fd
·
1 Parent(s): 6809354

Add application file

Browse files
Files changed (10) hide show
  1. Dockerfile +32 -0
  2. README.md +23 -1
  3. app.py +61 -0
  4. data/jobs.json +30 -0
  5. llm_client.py +65 -0
  6. models_schemas.py +30 -0
  7. parsers.py +29 -0
  8. prompts.py +51 -0
  9. requirements.txt +10 -0
  10. tests/sample.http +0 -0
Dockerfile ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Leia: https://huggingface.co/docs/hub/spaces-sdks-docker
2
+ FROM python:3.10-slim
3
+
4
+ # Usuário não-root (boa prática em Spaces)
5
+ RUN useradd -m -u 1000 user
6
+ USER root
7
+
8
+ ENV PIP_NO_CACHE_DIR=1 \
9
+ PYTHONDONTWRITEBYTECODE=1 \
10
+ PYTHONUNBUFFERED=1 \
11
+ PATH="/home/user/.local/bin:$PATH"
12
+
13
+ # Dependências nativas mínimas (pdf parsing)
14
+ RUN apt-get update && apt-get install -y --no-install-recommends \
15
+ build-essential poppler-utils \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ WORKDIR /app
19
+
20
+ COPY --chown=user:user requirements.txt /app/requirements.txt
21
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
22
+
23
+ COPY --chown=user:user . /app
24
+ USER user
25
+
26
+ # Variáveis (podem ser sobrescritas nas "Variables" do Space)
27
+ ENV PORT=7860
28
+ ENV GROQ_MODEL_ID=deepseek-r1-distill-llama-70b
29
+ ENV TEMPERATURE=0.7
30
+
31
+ # O Space escuta em 7860
32
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -7,4 +7,26 @@ sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  pinned: false
8
  ---
9
 
10
+ # RecrAI API (FastAPI + Groq) para Análise de Currículos
11
+
12
+ API que analisa CVs (PDF ou texto) contra uma vaga e retorna JSON estruturado (inclui `score`), pronta para rodar em Hugging Face Spaces via Docker.
13
+
14
+ ## Endpoints
15
+
16
+ ### GET /health
17
+ Retorna status da API.
18
+
19
+ ### POST /analyze_cv (multipart/form-data)
20
+ Campos:
21
+ - `job` (string) — descrição completa da vaga (obrigatório)
22
+ - `cv_text` (string) — texto do currículo (opcional se `file` for enviado)
23
+ - `file` (file/pdf) — PDF do currículo (opcional se `cv_text` for enviado)
24
+
25
+ ### POST /analyze_cv_batch (application/json)
26
+ ```json
27
+ {
28
+ "items": [
29
+ { "job": "texto da vaga", "cv_text": "texto do cv" },
30
+ { "job": "texto da vaga", "cv_pdf_b64": "<PDF em base64>" }
31
+ ]
32
+ }
app.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uvicorn
3
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
4
+ from fastapi.responses import JSONResponse
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from typing import List, Optional
7
+
8
+ from models_schemas import AnalyzeResponse, AnalyzeBatchRequest
9
+ from llm_client import analyze_cv_with_llm
10
+ from parsers import extract_text_from_pdf
11
+
12
+ app = FastAPI(title="RecrAI API", version="1.0.0")
13
+
14
+ app.add_middleware(
15
+ CORSMiddleware,
16
+ allow_origins=["*"], allow_credentials=True,
17
+ allow_methods=["*"], allow_headers=["*"]
18
+ )
19
+
20
+ @app.get("/health")
21
+ def health():
22
+ return {"status": "ok"}
23
+
24
+ @app.post("/analyze_cv", response_model=AnalyzeResponse)
25
+ async def analyze_cv_endpoint(
26
+ job: str = Form(..., description="Descrição completa da vaga"),
27
+ cv_text: Optional[str] = Form(None, description="Texto do currículo (alternativa a PDF)"),
28
+ file: Optional[UploadFile] = File(None, description="Arquivo PDF do currículo")
29
+ ):
30
+ if not cv_text and not file:
31
+ raise HTTPException(status_code=400, detail="Envie 'cv_text' ou 'file' (PDF).")
32
+
33
+ if file:
34
+ if not file.filename.lower().endswith(".pdf"):
35
+ raise HTTPException(status_code=415, detail="Apenas PDF é suportado no 'file'.")
36
+ pdf_bytes = await file.read()
37
+ cv_text = extract_text_from_pdf(pdf_bytes)
38
+
39
+ if not cv_text or not cv_text.strip():
40
+ raise HTTPException(status_code=422, detail="Não foi possível extrair texto do currículo.")
41
+
42
+ result = analyze_cv_with_llm(cv_text=cv_text, job_details=job)
43
+ return result
44
+
45
+ @app.post("/analyze_cv_batch", response_model=List[AnalyzeResponse])
46
+ async def analyze_cv_batch_endpoint(payload: AnalyzeBatchRequest):
47
+ results = []
48
+ for item in payload.items:
49
+ if not item.cv_text and not item.cv_pdf_b64:
50
+ raise HTTPException(status_code=400, detail="Cada item precisa de cv_text ou cv_pdf_b64.")
51
+ cv_text = item.cv_text
52
+ if not cv_text and item.cv_pdf_b64:
53
+ import base64
54
+ pdf_bytes = base64.b64decode(item.cv_pdf_b64)
55
+ cv_text = extract_text_from_pdf(pdf_bytes)
56
+ res = analyze_cv_with_llm(cv_text=cv_text, job_details=item.job)
57
+ results.append(res)
58
+ return results
59
+
60
+ if __name__ == "__main__":
61
+ uvicorn.run("app:app", host="0.0.0.0", port=int(os.getenv("PORT", "7860")))
data/jobs.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": 1,
4
+ "title": "Desenvolvedor(a) Full Stack Pleno",
5
+ "description": "Desenvolver e evoluir aplicações (front/back), integrações e CI/CD.",
6
+ "details": "React, Node, APIs REST, testes, boas práticas, cloud.",
7
+ "requirements": [
8
+ "React",
9
+ "Node",
10
+ "JavaScript",
11
+ "TypeScript",
12
+ "APIs REST",
13
+ "SQL",
14
+ "Docker"
15
+ ]
16
+ },
17
+ {
18
+ "id": 2,
19
+ "title": "Cientista de Dados Pleno",
20
+ "description": "Modelagem preditiva, EDA, métricas e MLOps.",
21
+ "details": "Pipelines, versionamento, documentação, comunicação.",
22
+ "requirements": [
23
+ "Pandas",
24
+ "Scikit-learn",
25
+ "Feature Engineering",
26
+ "Métricas",
27
+ "MLOps"
28
+ ]
29
+ }
30
+ ]
llm_client.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from typing import Dict, Any
4
+ from langchain_groq import ChatGroq
5
+ from langchain_core.prompts import ChatPromptTemplate
6
+ from models_schemas import AnalyzeResponse
7
+ from prompts import PROMPT_TEMPLATE, SCHEMA_JSON, PROMPT_SCORE
8
+
9
+ GROQ_MODEL_ID = os.getenv("GROQ_MODEL_ID", "deepseek-r1-distill-llama-70b")
10
+ TEMPERATURE = float(os.getenv("TEMPERATURE", "0.7"))
11
+
12
+ def _load_llm():
13
+ return ChatGroq(
14
+ model=GROQ_MODEL_ID,
15
+ temperature=TEMPERATURE,
16
+ max_tokens=None,
17
+ timeout=None,
18
+ max_retries=2,
19
+ )
20
+
21
+ def _strip_think(text: str) -> str:
22
+ if "</think>" in text:
23
+ return text.split("</think>")[-1].strip()
24
+ return text.strip()
25
+
26
+ def _force_schema_fields(d: Dict[str, Any]) -> Dict[str, Any]:
27
+ keys = [
28
+ "name","area","summary","skills","education","interview_questions",
29
+ "strengths","areas_for_development","important_considerations",
30
+ "final_recommendations","score"
31
+ ]
32
+ for k in keys:
33
+ if k not in d:
34
+ d[k] = [] if k in {
35
+ "skills","interview_questions","strengths",
36
+ "areas_for_development","important_considerations"
37
+ } else (0.0 if k == "score" else "")
38
+ return d
39
+
40
+ def analyze_cv_with_llm(cv_text: str, job_details: str) -> AnalyzeResponse:
41
+ llm = _load_llm()
42
+ prompt = ChatPromptTemplate.from_template(PROMPT_TEMPLATE)
43
+ chain = prompt | llm
44
+ output = chain.invoke({
45
+ "schema": SCHEMA_JSON,
46
+ "prompt_score": PROMPT_SCORE,
47
+ "cv": cv_text,
48
+ "job": job_details
49
+ })
50
+
51
+ raw = _strip_think(output.content)
52
+
53
+ start = raw.find("{")
54
+ end = raw.rfind("}")
55
+ if start == -1 or end == -1:
56
+ return AnalyzeResponse()
57
+
58
+ json_str = raw[start:end+1]
59
+ try:
60
+ data = json.loads(json_str)
61
+ except Exception:
62
+ return AnalyzeResponse()
63
+
64
+ data = _force_schema_fields(data)
65
+ return AnalyzeResponse(**data)
models_schemas.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+ from pydantic import BaseModel, Field, validator
3
+
4
+ class AnalyzeResponse(BaseModel):
5
+ name: Optional[str] = ""
6
+ area: Optional[str] = ""
7
+ summary: Optional[str] = ""
8
+ skills: List[str] = Field(default_factory=list)
9
+ education: Optional[str] = ""
10
+ interview_questions: List[str] = Field(default_factory=list)
11
+ strengths: List[str] = Field(default_factory=list)
12
+ areas_for_development: List[str] = Field(default_factory=list)
13
+ important_considerations: List[str] = Field(default_factory=list)
14
+ final_recommendations: Optional[str] = ""
15
+ score: float = 0.0
16
+
17
+ @validator("score", pre=True, always=True)
18
+ def coerce_score(cls, v):
19
+ try:
20
+ return float(v)
21
+ except Exception:
22
+ return 0.0
23
+
24
+ class AnalyzeBatchItem(BaseModel):
25
+ job: str
26
+ cv_text: Optional[str] = None
27
+ cv_pdf_b64: Optional[str] = None
28
+
29
+ class AnalyzeBatchRequest(BaseModel):
30
+ items: List[AnalyzeBatchItem]
parsers.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+
3
+ def extract_text_from_pdf(pdf_bytes: bytes) -> str:
4
+ """
5
+ Tenta PyMuPDF (fitz). Se falhar, tenta pdfminer.six (fallback).
6
+ """
7
+ text: Optional[str] = None
8
+
9
+ # 1) PyMuPDF
10
+ try:
11
+ import fitz # PyMuPDF
12
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
13
+ parts = []
14
+ for page in doc:
15
+ parts.append(page.get_text())
16
+ text = "\n".join(parts).strip()
17
+ except Exception:
18
+ text = None
19
+
20
+ # 2) pdfminer fallback
21
+ if not text:
22
+ try:
23
+ from io import BytesIO
24
+ from pdfminer.high_level import extract_text as pdfminer_extract
25
+ text = pdfminer_extract(BytesIO(pdf_bytes)).strip()
26
+ except Exception:
27
+ text = ""
28
+
29
+ return text or ""
prompts.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SCHEMA_JSON = """
2
+ {
3
+ "name": "Nome completo do candidato",
4
+ "area": "Área ou setor principal onde o candidato atua. Classifique em apenas uma: Desenvolvimento, Marketing, Vendas, Financeiro, Administrativo, Outros",
5
+ "summary": "Resumo objetivo sobre o perfil profissional do candidato",
6
+ "skills": ["competência 1", "competência 2", "..."],
7
+ "education": "Resumo da formação acadêmica mais relevante",
8
+ "interview_questions": ["Pelo menos 3 perguntas úteis para entrevista com base no currículo, para esclarecer algum ponto ou explorar melhor"],
9
+ "strengths": ["Pontos fortes e aspectos que indicam alinhamento com o perfil ou vaga desejada"],
10
+ "areas_for_development": ["Pontos que indicam possíveis lacunas, fragilidades ou necessidades de desenvolvimento"],
11
+ "important_considerations": ["Observações específicas que merecem verificação ou cuidado adicional"],
12
+ "final_recommendations": "Resumo avaliativo final com sugestões de próximos passos (ex: seguir com entrevista, indicar para outra vaga)",
13
+ "score": 0.0
14
+ }
15
+ """
16
+
17
+ PROMPT_SCORE = """
18
+ Com base na vaga específica, calcule a pontuação final (de 0.0 a 10.0).
19
+ O retorno para esse campo deve conter apenas a pontuação final (x.x) sem mais nenhum texto ou anotação.
20
+ Seja justo e rigoroso ao atribuir as notas. A nota 10.0 só deve ser atribuída para candidaturas que superem todas as expectativas da vaga.
21
+
22
+ Critérios de avaliação:
23
+ 1. Experiência (Peso: 35% do total): Análise de posições anteriores, tempo de atuação e similaridade com as responsabilidades da vaga.
24
+ 2. Habilidades Técnicas (Peso: 25% do total): Verifique o alinhamento das habilidades técnicas com os requisitos mencionados na vaga.
25
+ 3. Educação (Peso: 15% do total): Avalie a relevância da graduação/certificações para o cargo, incluindo instituições e anos de estudo.
26
+ 4. Pontos Fortes (Peso: 15% do total): Avalie a relevância dos pontos fortes (ou alinhamentos) para a vaga.
27
+ 5. Pontos Fracos (Desconto de até 10%): Avalie a gravidade dos pontos fracos (ou desalinhamentos) para a vaga.
28
+ """
29
+
30
+ PROMPT_TEMPLATE = """
31
+ Você é um especialista em Recursos Humanos com vasta experiência em análise de currículos.
32
+ Sua tarefa é analisar o conteúdo a seguir e extrair os dados conforme o formato abaixo, para cada um dos campos.
33
+ Responda apenas com o JSON estruturado e utilize somente essas chaves. Cuide para que os nomes das chaves sejam exatamente esses.
34
+ Não adicione explicações ou anotações fora do JSON.
35
+ Schema desejado:
36
+ {schema}
37
+
38
+ ---
39
+ Para o cálculo do campo score:
40
+ {prompt_score}
41
+
42
+ ---
43
+
44
+ Currículo a ser analisado:
45
+ '{cv}'
46
+
47
+ ---
48
+
49
+ Vaga que o candidato está se candidatando:
50
+ '{job}'
51
+ """
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ python-multipart==0.0.9
4
+
5
+ pydantic==2.9.2
6
+ langchain==0.2.16
7
+ langchain-groq==0.1.5
8
+
9
+ PyMuPDF==1.24.11
10
+ pdfminer.six==20231228
tests/sample.http ADDED
File without changes