Spaces:
Sleeping
Sleeping
Upload 3 files
Browse files- analyzer.py +195 -0
- main.py +76 -0
- requirements.txt +10 -0
analyzer.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import spacy
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
try:
|
| 5 |
+
nlp = spacy.load("en_core_web_sm")
|
| 6 |
+
except OSError:
|
| 7 |
+
import subprocess
|
| 8 |
+
subprocess.run(["python", "-m", "spacy", "download", "en_core_web_sm"])
|
| 9 |
+
nlp = spacy.load("en_core_web_sm")
|
| 10 |
+
|
| 11 |
+
_ner_pipeline = None
|
| 12 |
+
|
| 13 |
+
def get_ner_pipeline():
|
| 14 |
+
global _ner_pipeline
|
| 15 |
+
if _ner_pipeline is None:
|
| 16 |
+
try:
|
| 17 |
+
from transformers import pipeline
|
| 18 |
+
_ner_pipeline = pipeline("ner", model="dslim/bert-base-NER", grouped_entities=True)
|
| 19 |
+
except Exception:
|
| 20 |
+
_ner_pipeline = False
|
| 21 |
+
return _ner_pipeline if _ner_pipeline else None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
SKILLS_DB = [
|
| 25 |
+
"Python", "JavaScript", "TypeScript", "React", "Next.js", "Vue.js", "Angular",
|
| 26 |
+
"Node.js", "Express.js", "FastAPI", "Flask", "Django", "Spring Boot",
|
| 27 |
+
"Machine Learning", "Deep Learning", "TensorFlow", "PyTorch", "Scikit-learn",
|
| 28 |
+
"Keras", "XGBoost", "LightGBM", "CatBoost",
|
| 29 |
+
"SQL", "MySQL", "PostgreSQL", "MongoDB", "Redis", "Cassandra", "SQLite",
|
| 30 |
+
"Docker", "Kubernetes", "AWS", "GCP", "Azure", "Terraform", "CI/CD", "Jenkins",
|
| 31 |
+
"Git", "GitHub", "GitLab", "Bitbucket",
|
| 32 |
+
"Linux", "REST API", "GraphQL", "gRPC", "WebSockets",
|
| 33 |
+
"HTML", "CSS", "Tailwind CSS", "Bootstrap", "SASS",
|
| 34 |
+
"Java", "C++", "C", "Go", "Rust", "R", "Scala", "Kotlin",
|
| 35 |
+
"Pandas", "NumPy", "Matplotlib", "Seaborn", "Plotly",
|
| 36 |
+
"NLP", "Computer Vision", "LLM", "Hugging Face", "OpenCV", "NLTK", "spaCy",
|
| 37 |
+
"Tableau", "Power BI", "Excel", "Spark", "Hadoop", "Kafka",
|
| 38 |
+
"Selenium", "Pytest", "Jest", "JUnit", "Postman",
|
| 39 |
+
"Figma", "Jira", "Agile", "Scrum"
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
ROLE_RULES = [
|
| 43 |
+
(["Machine Learning", "Deep Learning", "TensorFlow", "PyTorch", "Scikit-learn"], "ML/AI Engineer"),
|
| 44 |
+
(["NLP", "NLTK", "spaCy", "Hugging Face", "LLM"], "NLP Engineer"),
|
| 45 |
+
(["Computer Vision", "OpenCV"], "Computer Vision Engineer"),
|
| 46 |
+
(["React", "Next.js", "Vue.js", "Angular", "HTML", "CSS", "Tailwind CSS"], "Frontend Developer"),
|
| 47 |
+
(["Node.js", "FastAPI", "Flask", "Django", "Express.js", "Spring Boot"], "Backend Developer"),
|
| 48 |
+
(["Docker", "Kubernetes", "CI/CD", "Terraform", "Jenkins", "AWS", "GCP", "Azure"], "DevOps/Cloud Engineer"),
|
| 49 |
+
(["SQL", "PostgreSQL", "MySQL", "MongoDB", "Spark", "Hadoop", "Kafka", "Tableau", "Power BI"], "Data Engineer/Analyst"),
|
| 50 |
+
(["React", "Node.js", "MongoDB", "FastAPI", "Flask", "PostgreSQL"], "Full Stack Developer"),
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
SKILL_CATEGORIES = {
|
| 54 |
+
"Languages": ["Python", "JavaScript", "TypeScript", "Java", "C++", "C", "Go", "Rust", "R", "Scala", "Kotlin"],
|
| 55 |
+
"Frontend": ["React", "Next.js", "Vue.js", "Angular", "HTML", "CSS", "Tailwind CSS", "Bootstrap", "SASS"],
|
| 56 |
+
"Backend": ["Node.js", "Express.js", "FastAPI", "Flask", "Django", "Spring Boot", "REST API", "GraphQL", "gRPC"],
|
| 57 |
+
"ML/AI": ["Machine Learning", "Deep Learning", "TensorFlow", "PyTorch", "Scikit-learn", "Keras", "XGBoost", "NLP", "Computer Vision", "Hugging Face", "LLM", "OpenCV", "NLTK", "spaCy"],
|
| 58 |
+
"Databases": ["SQL", "MySQL", "PostgreSQL", "MongoDB", "Redis", "Cassandra", "SQLite"],
|
| 59 |
+
"DevOps/Cloud": ["Docker", "Kubernetes", "AWS", "GCP", "Azure", "Terraform", "CI/CD", "Jenkins", "Linux"],
|
| 60 |
+
"Data Tools": ["Pandas", "NumPy", "Matplotlib", "Seaborn", "Plotly", "Tableau", "Power BI", "Spark", "Hadoop", "Kafka"],
|
| 61 |
+
"Tools": ["Git", "GitHub", "GitLab", "Jira", "Postman", "Figma", "Selenium", "Pytest", "Jest"],
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def extract_skills(text):
|
| 66 |
+
return list({skill for skill in SKILLS_DB if re.search(r'\b' + re.escape(skill) + r'\b', text, re.IGNORECASE)})
|
| 67 |
+
|
| 68 |
+
def categorize_skills(skills):
|
| 69 |
+
return {cat: [s for s in skills if s in cat_skills] for cat, cat_skills in SKILL_CATEGORIES.items() if any(s in cat_skills for s in skills)}
|
| 70 |
+
|
| 71 |
+
def extract_experience_years(text):
|
| 72 |
+
matches = re.findall(r'(\d+)[\+]?\s*(?:years?|yrs?)\s*(?:of)?\s*(?:experience|exp)', text, re.IGNORECASE)
|
| 73 |
+
matches += re.findall(r'experience\s*(?:of)?\s*(\d+)[\+]?\s*(?:years?|yrs?)', text, re.IGNORECASE)
|
| 74 |
+
return max(int(m) for m in matches) if matches else 0
|
| 75 |
+
|
| 76 |
+
def extract_education(text):
|
| 77 |
+
degrees = ["B.Tech", "B.E", "B.Sc", "M.Tech", "M.Sc", "MCA", "BCA", "MBA", "Ph.D", "Bachelor", "Master", "Doctorate"]
|
| 78 |
+
return list({d for d in degrees if re.search(r'\b' + re.escape(d) + r'\b', text, re.IGNORECASE)})
|
| 79 |
+
|
| 80 |
+
def extract_email(text):
|
| 81 |
+
m = re.search(r'[\w.+-]+@[\w-]+\.[\w.]+', text)
|
| 82 |
+
return m.group(0) if m else None
|
| 83 |
+
|
| 84 |
+
def extract_phone(text):
|
| 85 |
+
m = re.search(r'(?:\+91[\s-]?)?[6-9]\d{9}|(?:\+\d{1,3}[\s-]?)?\(?\d{3}\)?[\s-]?\d{3}[\s-]?\d{4}', text)
|
| 86 |
+
return m.group(0) if m else None
|
| 87 |
+
|
| 88 |
+
def extract_github(text):
|
| 89 |
+
m = re.search(r'github\.com/([\w-]+)', text, re.IGNORECASE)
|
| 90 |
+
return f"github.com/{m.group(1)}" if m else None
|
| 91 |
+
|
| 92 |
+
def extract_linkedin(text):
|
| 93 |
+
m = re.search(r'linkedin\.com/in/([\w-]+)', text, re.IGNORECASE)
|
| 94 |
+
return f"linkedin.com/in/{m.group(1)}" if m else None
|
| 95 |
+
|
| 96 |
+
def extract_name(text):
|
| 97 |
+
ner = get_ner_pipeline()
|
| 98 |
+
if ner:
|
| 99 |
+
try:
|
| 100 |
+
for ent in ner(text[:512]):
|
| 101 |
+
if ent["entity_group"] == "PER":
|
| 102 |
+
return ent["word"]
|
| 103 |
+
except Exception:
|
| 104 |
+
pass
|
| 105 |
+
doc = nlp(text[:500])
|
| 106 |
+
for ent in doc.ents:
|
| 107 |
+
if ent.label_ == "PERSON":
|
| 108 |
+
return ent.text
|
| 109 |
+
for line in text.strip().split('\n'):
|
| 110 |
+
line = line.strip()
|
| 111 |
+
if line and 2 < len(line) < 60 and not re.search(r'[@http]|resume|cv|summary|objective', line, re.IGNORECASE):
|
| 112 |
+
return line
|
| 113 |
+
return "Unknown"
|
| 114 |
+
|
| 115 |
+
def predict_role(skills):
|
| 116 |
+
best_role, best_score = "Software Developer", 0
|
| 117 |
+
for rule_skills, role in ROLE_RULES:
|
| 118 |
+
score = sum(1 for s in skills if s in rule_skills)
|
| 119 |
+
if score > best_score:
|
| 120 |
+
best_score, best_role = score, role
|
| 121 |
+
return best_role
|
| 122 |
+
|
| 123 |
+
def compute_score(skills, experience, education):
|
| 124 |
+
score = min(len(skills) * 3, 40) + min(experience * 5, 30)
|
| 125 |
+
score += 10 if education else 0
|
| 126 |
+
score += 5 if len(skills) > 10 else 0
|
| 127 |
+
score += 5 if experience > 2 else 0
|
| 128 |
+
score += min(len(categorize_skills(skills)) * 2, 10)
|
| 129 |
+
return min(score, 100)
|
| 130 |
+
|
| 131 |
+
def get_ats_score(text, skills):
|
| 132 |
+
score = 0
|
| 133 |
+
wc = len(text.split())
|
| 134 |
+
if wc > 200: score += 20
|
| 135 |
+
if wc > 400: score += 10
|
| 136 |
+
score += min(len(skills) * 2, 30)
|
| 137 |
+
for kw in ['experience|work|employment', 'education|degree|university', 'project|portfolio|github', 'achievement|award|certification']:
|
| 138 |
+
if re.search(kw, text, re.IGNORECASE): score += 10
|
| 139 |
+
return min(score, 100)
|
| 140 |
+
|
| 141 |
+
def match_job_description(resume_skills, jd_text):
|
| 142 |
+
jd_skills = extract_skills(jd_text)
|
| 143 |
+
if not jd_skills:
|
| 144 |
+
return {"match_score": 0, "matched_skills": [], "missing_skills": [], "jd_skills_total": 0}
|
| 145 |
+
matched = [s for s in jd_skills if s in resume_skills]
|
| 146 |
+
missing = [s for s in jd_skills if s not in resume_skills]
|
| 147 |
+
return {"match_score": int(len(matched)/len(jd_skills)*100), "matched_skills": matched, "missing_skills": missing, "jd_skills_total": len(jd_skills)}
|
| 148 |
+
|
| 149 |
+
def get_section_checklist(text):
|
| 150 |
+
return {
|
| 151 |
+
"Contact Info": bool(re.search(r'email|phone|linkedin|github|@', text, re.IGNORECASE)),
|
| 152 |
+
"Summary/Objective": bool(re.search(r'summary|objective|profile|about', text, re.IGNORECASE)),
|
| 153 |
+
"Skills": bool(re.search(r'skill|technologies|tech stack|tools', text, re.IGNORECASE)),
|
| 154 |
+
"Experience": bool(re.search(r'experience|work|employment|internship', text, re.IGNORECASE)),
|
| 155 |
+
"Education": bool(re.search(r'education|degree|university|college|b\.tech|m\.tech|bca|mca', text, re.IGNORECASE)),
|
| 156 |
+
"Projects": bool(re.search(r'project|built|developed|created|implemented', text, re.IGNORECASE)),
|
| 157 |
+
"Certifications": bool(re.search(r'certification|certified|certificate|course', text, re.IGNORECASE)),
|
| 158 |
+
"Achievements": bool(re.search(r'achievement|award|honor|winner|rank|prize', text, re.IGNORECASE)),
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
def suggest_improvements(skills, experience, education, text):
|
| 162 |
+
suggestions = []
|
| 163 |
+
skill_set = set(s.lower() for s in skills)
|
| 164 |
+
if "docker" not in skill_set: suggestions.append({"type": "skill", "msg": "Add Docker for containerization knowledge"})
|
| 165 |
+
if "git" not in skill_set: suggestions.append({"type": "skill", "msg": "Mention Git version control experience"})
|
| 166 |
+
if not any(c in skill_set for c in ["aws", "gcp", "azure"]): suggestions.append({"type": "skill", "msg": "Add cloud platform experience (AWS/GCP/Azure)"})
|
| 167 |
+
if not any(db in skill_set for db in ["sql", "mysql", "postgresql", "mongodb"]): suggestions.append({"type": "skill", "msg": "Include database skills (SQL, MongoDB, etc.)"})
|
| 168 |
+
if len(skills) < 6: suggestions.append({"type": "content", "msg": "List more technical skills to improve ATS visibility"})
|
| 169 |
+
if experience == 0: suggestions.append({"type": "content", "msg": "Explicitly mention years of experience or internship duration"})
|
| 170 |
+
if not education: suggestions.append({"type": "content", "msg": "Add your educational qualifications clearly"})
|
| 171 |
+
if not re.search(r'github\.com', text, re.IGNORECASE): suggestions.append({"type": "link", "msg": "Add your GitHub profile link to showcase projects"})
|
| 172 |
+
if not re.search(r'linkedin\.com', text, re.IGNORECASE): suggestions.append({"type": "link", "msg": "Add your LinkedIn profile for professional presence"})
|
| 173 |
+
if not re.search(r'project', text, re.IGNORECASE): suggestions.append({"type": "content", "msg": "Include a Projects section with tech stack and impact"})
|
| 174 |
+
if not re.search(r'certification|certified', text, re.IGNORECASE): suggestions.append({"type": "content", "msg": "Add certifications (Coursera, Google, AWS, etc.) to stand out"})
|
| 175 |
+
return suggestions
|
| 176 |
+
|
| 177 |
+
def analyze_resume(text: str, jd_text: str = None) -> dict:
|
| 178 |
+
skills = extract_skills(text)
|
| 179 |
+
experience = extract_experience_years(text)
|
| 180 |
+
education = extract_education(text)
|
| 181 |
+
return {
|
| 182 |
+
"name": extract_name(text),
|
| 183 |
+
"contact": {"email": extract_email(text), "phone": extract_phone(text), "github": extract_github(text), "linkedin": extract_linkedin(text)},
|
| 184 |
+
"skills": skills,
|
| 185 |
+
"skills_by_category": categorize_skills(skills),
|
| 186 |
+
"experience_years": experience,
|
| 187 |
+
"education": education,
|
| 188 |
+
"predicted_role": predict_role(skills),
|
| 189 |
+
"resume_score": compute_score(skills, experience, education),
|
| 190 |
+
"ats_score": get_ats_score(text, skills),
|
| 191 |
+
"suggestions": suggest_improvements(skills, experience, education, text),
|
| 192 |
+
"section_checklist": get_section_checklist(text),
|
| 193 |
+
"jd_match": match_job_description(skills, jd_text) if jd_text else None,
|
| 194 |
+
"word_count": len(text.split()),
|
| 195 |
+
}
|
main.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException, Form
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from typing import Optional
|
| 5 |
+
import pdfplumber
|
| 6 |
+
import docx
|
| 7 |
+
import io
|
| 8 |
+
from analyzer import analyze_resume
|
| 9 |
+
|
| 10 |
+
app = FastAPI(
|
| 11 |
+
title="Resume Analyzer API",
|
| 12 |
+
description="Analyze resumes using NLP and pretrained models",
|
| 13 |
+
version="3.0.0"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
app.add_middleware(
|
| 17 |
+
CORSMiddleware,
|
| 18 |
+
allow_origins=["*"],
|
| 19 |
+
allow_credentials=True,
|
| 20 |
+
allow_methods=["*"],
|
| 21 |
+
allow_headers=["*"],
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TextRequest(BaseModel):
|
| 26 |
+
text: str
|
| 27 |
+
jd_text: Optional[str] = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def extract_text_from_pdf(file_bytes: bytes) -> str:
|
| 31 |
+
text = ""
|
| 32 |
+
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
|
| 33 |
+
for page in pdf.pages:
|
| 34 |
+
page_text = page.extract_text()
|
| 35 |
+
if page_text:
|
| 36 |
+
text += page_text + "\n"
|
| 37 |
+
return text.strip()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def extract_text_from_docx(file_bytes: bytes) -> str:
|
| 41 |
+
doc = docx.Document(io.BytesIO(file_bytes))
|
| 42 |
+
return "\n".join([para.text for para in doc.paragraphs if para.text.strip()])
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@app.get("/")
|
| 46 |
+
def root():
|
| 47 |
+
return {"message": "Resume Analyzer API v3.0 is running ✅", "version": "3.0.0"}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@app.post("/analyze/text")
|
| 51 |
+
def analyze_text(request: TextRequest):
|
| 52 |
+
if not request.text or len(request.text.strip()) < 50:
|
| 53 |
+
raise HTTPException(status_code=400, detail="Resume text too short.")
|
| 54 |
+
return analyze_resume(request.text, request.jd_text)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@app.post("/analyze/file")
|
| 58 |
+
async def analyze_file(
|
| 59 |
+
file: UploadFile = File(...),
|
| 60 |
+
jd_text: Optional[str] = Form(None)
|
| 61 |
+
):
|
| 62 |
+
content = await file.read()
|
| 63 |
+
|
| 64 |
+
if file.filename.endswith(".pdf"):
|
| 65 |
+
text = extract_text_from_pdf(content)
|
| 66 |
+
elif file.filename.endswith(".docx"):
|
| 67 |
+
text = extract_text_from_docx(content)
|
| 68 |
+
elif file.filename.endswith(".txt"):
|
| 69 |
+
text = content.decode("utf-8", errors="ignore")
|
| 70 |
+
else:
|
| 71 |
+
raise HTTPException(status_code=400, detail="Unsupported file. Upload PDF, DOCX, or TXT.")
|
| 72 |
+
|
| 73 |
+
if not text or len(text.strip()) < 50:
|
| 74 |
+
raise HTTPException(status_code=400, detail="Could not extract text from file.")
|
| 75 |
+
|
| 76 |
+
return analyze_resume(text, jd_text)
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn==0.30.1
|
| 3 |
+
transformers==4.41.2
|
| 4 |
+
torch==2.3.1
|
| 5 |
+
pdfplumber==0.11.0
|
| 6 |
+
python-docx==1.1.2
|
| 7 |
+
pydantic==2.7.3
|
| 8 |
+
httpx==0.27.0
|
| 9 |
+
python-multipart==0.0.9
|
| 10 |
+
spacy==3.8.3
|