├── tests ├── __init__.py ├── utils │ ├── enums.py │ └── tools.py ├── test_auth.py ├── test_rate_limit.py ├── test_job.py └── test_text.py ├── .flake8 ├── .gitmodules ├── hrflow ├── __init__.py ├── job │ ├── reasoning.py │ ├── parsing.py │ ├── __init__.py │ ├── embedding.py │ ├── asking.py │ ├── searching.py │ ├── scoring.py │ ├── matching.py │ └── storing.py ├── profile │ ├── reasoning.py │ ├── revealing.py │ ├── attachment.py │ ├── embedding.py │ ├── __init__.py │ ├── asking.py │ ├── unfolding.py │ ├── searching.py │ ├── scoring.py │ ├── grading.py │ ├── matching.py │ └── parsing.py ├── utils │ ├── __init__.py │ ├── storing.py │ ├── searching.py │ ├── scoring.py │ └── evaluation │ │ ├── __init__.py │ │ └── job.py ├── __version__.py ├── webhook │ ├── hmacutils.py │ ├── base64Wrapper.py │ ├── bytesutils.py │ └── __init__.py ├── text │ ├── embedding.py │ ├── linking.py │ ├── __init__.py │ ├── ocr.py │ ├── imaging.py │ ├── parsing.py │ └── tagging.py ├── auth │ └── __init__.py ├── core │ ├── __init__.py │ ├── rate_limit.py │ └── validation.py ├── board │ └── __init__.py ├── source │ └── __init__.py ├── rating │ └── __init__.py ├── tracking │ └── __init__.py ├── hrflow.py └── schemas.py ├── check.sh ├── .env.example ├── Makefile ├── LICENSE.txt ├── tag.sh ├── pyproject.toml ├── .gitignore ├── README.md └── examples └── job └── job_endpoints_examples.ipynb /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | exclude = .pytest_cache, __pycache__, .env, .venv 4 | black-config = pyproject.toml 5 | per-file-ignores = __init__.py:F401 6 | ignore = E731, W503, E203 -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "libs/exporter"] 2 | path = libs/exporter 3 | url = git@github.com:Riminder/hrflow-exporter.git 4 | [submodule "libs/importer"] 5 | path = libs/importer 6 | url = git@github.com:Riminder/hrflow-importer.git 7 | -------------------------------------------------------------------------------- /hrflow/__init__.py: -------------------------------------------------------------------------------- 1 | from .__version__ import ( 2 | __author__, 3 | __author_email__, 4 | __description__, 5 | __license__, 6 | __title__, 7 | __url__, 8 | __version__, 9 | ) 10 | from .hrflow import Hrflow 11 | -------------------------------------------------------------------------------- /hrflow/job/reasoning.py: -------------------------------------------------------------------------------- 1 | class JobReasoning: 2 | """Manage embedding related profile calls.""" 3 | 4 | def __init__(self, api): 5 | """Init.""" 6 | self.client = api 7 | 8 | def get(self): 9 | return 10 | -------------------------------------------------------------------------------- /hrflow/profile/reasoning.py: -------------------------------------------------------------------------------- 1 | class ProfileReasoning: 2 | """Manage embedding related profile calls.""" 3 | 4 | def __init__(self, api): 5 | """Init.""" 6 | self.client = api 7 | 8 | def get(self): 9 | return 10 | -------------------------------------------------------------------------------- /hrflow/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # noqa: F401 2 | from .evaluation import generate_parsing_evaluation_report 3 | from .scoring import is_valid_for_scoring 4 | from .searching import is_valid_for_searching 5 | from .storing import get_all_jobs, get_all_profiles 6 | -------------------------------------------------------------------------------- /hrflow/job/parsing.py: -------------------------------------------------------------------------------- 1 | class JobParsing: 2 | """Manage parsing related job calls.""" 3 | 4 | def __init__(self, api): 5 | """Init.""" 6 | self.client = api 7 | 8 | def get(self, job_id=None, job_reference=None): 9 | return 10 | -------------------------------------------------------------------------------- /hrflow/__version__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | __title__ = "hrflow" 4 | __description__ = "Python hrflow.ai API package" 5 | __url__ = "https://github.com/hrflow/python-hrflow-api" 6 | __version__ = importlib.metadata.version("hrflow") 7 | __author__ = "HrFlow.ai" 8 | __author_email__ = "contact@hrflow.ai" 9 | __license__ = "MIT" 10 | -------------------------------------------------------------------------------- /check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PYTEST_RUN="poetry run pytest" 4 | PYTEST_OPTIONS=(--verbose --tb=long --strict-markers --durations=0 --datefmt "%Y-%m-%d %H:%M:%S.%f%z") 5 | PYTEST_DIR=tests/ 6 | 7 | if [ "$#" -gt 0 ]; then 8 | for marker in "$@"; do 9 | $PYTEST_RUN "${PYTEST_OPTIONS[@]}" "$PYTEST_DIR" -m "$marker" 10 | done 11 | else 12 | $PYTEST_RUN "${PYTEST_OPTIONS[@]}" "$PYTEST_DIR" 13 | fi 14 | -------------------------------------------------------------------------------- /hrflow/webhook/hmacutils.py: -------------------------------------------------------------------------------- 1 | """Some utils for hash(py2 compatibility).""" 2 | 3 | import hmac 4 | import sys 5 | 6 | 7 | def _compare_digest_py2(a, b): 8 | return a == b 9 | 10 | 11 | def _compare_digest_py3(a, b): 12 | return hmac.compare_digest(a, b) 13 | 14 | 15 | def compare_digest(a, b): 16 | """Compare 2 hash digest.""" 17 | py_version = sys.version_info[0] 18 | if py_version >= 3: 19 | return _compare_digest_py3(a, b) 20 | return _compare_digest_py2(a, b) 21 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | HRFLOW_API_KEY="___FILL_ME___" 2 | HRFLOW_API_KEY_READ="___FILL_ME___" 3 | HRFLOW_USER_EMAIL="___FILL_ME___" 4 | HRFLOW_ALGORITHM_KEY="___FILL_ME___" 5 | HRFLOW_BOARD_KEY="___FILL_ME___" 6 | HRFLOW_JOB_KEY="___JOB_KEY_IN_BOARD___" 7 | HRFLOW_PROFILE_KEY="___PROFILE_KEY_IN_SOURCE_QUICKSILVER_SYNC___" 8 | HRFLOW_SOURCE_KEY_HAWK_SYNC="___FILL_ME___" 9 | HRFLOW_SOURCE_KEY_QUICKSILVER_SYNC="___FILL_ME___" 10 | HRFLOW_SOURCE_KEY_QUICKSILVER_ASYNC="___FILL_ME___" 11 | HRFLOW_SOURCE_KEY_MOZART_ASYNC="___FILL_ME___" -------------------------------------------------------------------------------- /tests/utils/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class TAGGING_ALGORITHM(str, Enum): 5 | TAGGER_ROME_FAMILY = "tagger-rome-family" 6 | TAGGER_ROME_SUBFAMILY = "tagger-rome-subfamily" 7 | TAGGER_ROME_CATEGORY = "tagger-rome-category" 8 | TAGGER_ROME_JOBTITLE = "tagger-rome-jobtitle" 9 | TAGGER_HRFLOW_SKILLS = "tagger-hrflow-skills" 10 | TAGGER_HRFLOW_LABELS = "tagger-hrflow-labels" 11 | 12 | 13 | class PERMISSION(str, Enum): 14 | ALL = "all" 15 | WRITE = "write" 16 | READ = "read" 17 | -------------------------------------------------------------------------------- /hrflow/webhook/base64Wrapper.py: -------------------------------------------------------------------------------- 1 | """A wrapper for base64 (python2 compatibility).""" 2 | 3 | import base64 4 | import sys 5 | 6 | 7 | def _decodebytes_py3(input): 8 | return base64.decodebytes(input) 9 | 10 | 11 | def _decodebytes_py2(input): 12 | return base64.b64decode(input) 13 | 14 | 15 | def decodebytes(input): 16 | """Decode base64 string to byte array.""" 17 | py_version = sys.version_info[0] 18 | if py_version >= 3: 19 | return _decodebytes_py3(input) 20 | return _decodebytes_py2(input) 21 | -------------------------------------------------------------------------------- /hrflow/webhook/bytesutils.py: -------------------------------------------------------------------------------- 1 | """Some function to manipulate bytes(py2 compatibility).""" 2 | 3 | import sys 4 | 5 | 6 | def _strtobytes_py2(input, encoding): 7 | return bytearray(input, encoding) 8 | 9 | 10 | def _strtobytes_py3(input, encoding): 11 | return bytes(input, encoding) 12 | 13 | 14 | def strtobytes(input, encoding): 15 | """Take a str and transform it into a byte array.""" 16 | py_version = sys.version_info[0] 17 | if py_version >= 3: 18 | return _strtobytes_py3(input, encoding) 19 | return _strtobytes_py2(input, encoding) 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Define variables 2 | ARGS := 3 | 4 | clean: clean_cache 5 | rm -rf build dist *.egg-info 6 | 7 | clean_cache: 8 | find . -type d \( -name '__pycache__' -o -name '.pytest_cache' \) -exec rm -rf {} + 9 | rm -rf tests/assets 10 | 11 | build: 12 | poetry build 13 | 14 | git-tag: 15 | ./tag.sh $(ARGS) 16 | 17 | deploy-test: 18 | poetry publish -r test-pypi --build 19 | 20 | deploy: 21 | poetry publish --build 22 | 23 | flake8: 24 | poetry run flake8 --config=./.flake8 25 | 26 | style: 27 | poetry run isort . && poetry run black --config=./pyproject.toml . 28 | 29 | check: 30 | bash ./check.sh -------------------------------------------------------------------------------- /hrflow/job/__init__.py: -------------------------------------------------------------------------------- 1 | from .asking import JobAsking 2 | from .embedding import JobEmbedding 3 | from .matching import JobMatching 4 | from .parsing import JobParsing 5 | from .reasoning import JobReasoning 6 | from .scoring import JobScoring 7 | from .searching import JobSearching 8 | from .storing import JobStoring 9 | 10 | 11 | class Job: 12 | def __init__(self, client): 13 | self.client = client 14 | self.asking = JobAsking(self.client) 15 | self.parsing = JobParsing(self.client) 16 | self.embedding = JobEmbedding(self.client) 17 | self.searching = JobSearching(self.client) 18 | self.scoring = JobScoring(self.client) 19 | self.reasoning = JobReasoning(self.client) 20 | self.storing = JobStoring(self.client) 21 | self.matching = JobMatching(self.client) 22 | -------------------------------------------------------------------------------- /hrflow/text/embedding.py: -------------------------------------------------------------------------------- 1 | from ..core.rate_limit import rate_limiter 2 | from ..core.validation import validate_response 3 | 4 | 5 | class TextEmbedding: 6 | """Manage embedding related profile calls.""" 7 | 8 | def __init__(self, api): 9 | """Init.""" 10 | self.client = api 11 | 12 | @rate_limiter 13 | def post(self, text): 14 | """ 15 | This endpoint allows you to vectorize a Text. 16 | 17 | 18 | 19 | Args: 20 | text : 21 | The text to vectorize. 22 | Returns 23 | Embedding vector of the text. 24 | 25 | """ 26 | payload = { 27 | "text": text, 28 | } 29 | response = self.client.post("text/embedding", json=payload) 30 | 31 | return validate_response(response) 32 | -------------------------------------------------------------------------------- /hrflow/text/linking.py: -------------------------------------------------------------------------------- 1 | from ..core.rate_limit import rate_limiter 2 | from ..core.validation import validate_response 3 | 4 | 5 | class TextLinking: 6 | """Manage Text linking calls.""" 7 | 8 | def __init__(self, api): 9 | """Init.""" 10 | self.client = api 11 | 12 | @rate_limiter 13 | def post(self, word, top_n=5): 14 | """ 15 | Find synonyms or the top N most similar words to a word. 16 | 17 | Args: 18 | text: 19 | text 20 | top_n: 21 | top_n 22 | Returns 23 | Find synonyms or the top N most similar words to a word. 24 | """ 25 | payload = {"word": word, "top_n": top_n} 26 | response = self.client.post("text/linking", json=payload) 27 | return validate_response(response) 28 | -------------------------------------------------------------------------------- /hrflow/text/__init__.py: -------------------------------------------------------------------------------- 1 | """Profile related calls.""" 2 | 3 | from .embedding import TextEmbedding 4 | from .imaging import TextImaging 5 | from .linking import TextLinking 6 | from .ocr import TextOCR 7 | from .parsing import TextParsing 8 | from .tagging import TextTagging 9 | 10 | 11 | class Text(object): 12 | """Text related calls.""" 13 | 14 | def __init__(self, client): 15 | """ 16 | Initialize Profile object with hrflow client. 17 | 18 | Args: 19 | client: hrflow client instance 20 | 21 | Returns 22 | Profile instance object. 23 | 24 | """ 25 | self.client = client 26 | self.parsing = TextParsing(self.client) 27 | self.linking = TextLinking(self.client) 28 | self.embedding = TextEmbedding(self.client) 29 | self.tagging = TextTagging(self.client) 30 | self.ocr = TextOCR(self.client) 31 | self.imaging = TextImaging(self.client) 32 | -------------------------------------------------------------------------------- /hrflow/text/ocr.py: -------------------------------------------------------------------------------- 1 | from ..core.rate_limit import rate_limiter 2 | from ..core.validation import validate_response 3 | 4 | 5 | class TextOCR: 6 | "Manage Text extraction from documents using in-house advance OCR related calls." 7 | 8 | def __init__(self, api): 9 | """Init.""" 10 | self.client = api 11 | 12 | @rate_limiter 13 | def post(self, file): 14 | """ 15 | This endpoint allows you to extract a the text from a document across all 16 | formats (pdf, docx, png, and more). 17 | Supported extensions by the Profile Parsing API are .pdf, .png, .jpg, .jpeg, 18 | .bmp, .doc, .docx, .odt, .rtf, .odp, ppt, and .pptx. 19 | 20 | Args: 21 | file: 22 | binary file content 23 | Returns 24 | Extracted text along with pages, blocks and more 25 | 26 | """ 27 | response = self.client.post("text/ocr", files={"file": file}) 28 | return validate_response(response) 29 | -------------------------------------------------------------------------------- /hrflow/job/embedding.py: -------------------------------------------------------------------------------- 1 | from ..core import format_item_payload 2 | from ..core.rate_limit import rate_limiter 3 | from ..core.validation import validate_response 4 | 5 | 6 | class JobEmbedding: 7 | """Manage embedding related job calls.""" 8 | 9 | def __init__(self, api): 10 | """Init.""" 11 | self.client = api 12 | 13 | @rate_limiter 14 | def get(self, board_key, key=None, reference=None): 15 | """ 16 | Retrieve the parsing information. 17 | 18 | Args: 19 | board_key: 20 | board id 21 | key: 22 | job id 23 | reference: 24 | job_reference 25 | 26 | Returns 27 | parsing information 28 | 29 | """ 30 | query_params = format_item_payload("job", board_key, key, reference) 31 | response = self.client.get("job/embedding", query_params) 32 | return validate_response(response) 33 | -------------------------------------------------------------------------------- /hrflow/auth/__init__.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from ..core.rate_limit import rate_limiter 4 | from ..core.validation import validate_key, validate_response 5 | 6 | API_SECRET_REGEX = r"^ask[rw]?_[0-9a-f]{32}$" 7 | 8 | 9 | class Auth: 10 | def __init__(self, api): 11 | self.client = api 12 | 13 | @rate_limiter 14 | def get(self) -> t.Dict[str, t.Any]: 15 | """ 16 | Try your API Keys. This endpoint allows you to learn how to add the right 17 | information to your API calls, so you can make them. 18 | 19 | Args: 20 | api_user: 21 | Your HrFlow.ai account's email. 22 | api_secret: 23 | Your API Key. 24 | 25 | Returns: 26 | `/auth` response 27 | """ 28 | 29 | validate_key( 30 | "api_secret", 31 | self.client.auth_header.get("X-API-KEY"), 32 | regex=API_SECRET_REGEX, 33 | ) 34 | 35 | response = self.client.get("auth") 36 | 37 | return validate_response(response) 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 HrFlow.ai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tag.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BUMP_MAJOR="major" 4 | BUMP_MINOR="minor" 5 | BUMP_PATCH="patch" 6 | 7 | DEFAULT_BUMP=$BUMP_PATCH 8 | 9 | # Parse argument to get the bump type 10 | if [ $# -eq 0 ]; then 11 | BUMP=$DEFAULT_BUMP 12 | else 13 | BUMP=$1 14 | fi 15 | 16 | # Get the latest tag using `git describe` 17 | LATEST_TAG=$(git describe --abbrev=0 --tags) 18 | 19 | # Extract version numbers from the latest tag 20 | VERSION=$(echo "$LATEST_TAG" | sed 's/v//') 21 | 22 | # Split version into major, minor, and patch components 23 | IFS='.' read -ra VERSION_ARRAY <<< "$VERSION" 24 | MAJOR=${VERSION_ARRAY[0]} 25 | MINOR=${VERSION_ARRAY[1]} 26 | PATCH=${VERSION_ARRAY[2]} 27 | 28 | # Increment the version based on the bump type 29 | if [ "$BUMP" = "$BUMP_MAJOR" ]; then 30 | MAJOR=$((MAJOR + 1)) 31 | MINOR=0 32 | PATCH=0 33 | elif [ "$BUMP" = "$BUMP_MINOR" ]; then 34 | MINOR=$((MINOR + 1)) 35 | PATCH=0 36 | elif [ "$BUMP" = "$BUMP_PATCH" ]; then 37 | PATCH=$((PATCH + 1)) 38 | else 39 | echo "Unknown bump type. Please use either 'major', 'minor', or 'patch'." 40 | exit 1 41 | fi 42 | 43 | # Construct the new tag 44 | NEW_TAG="v$MAJOR.$MINOR.$PATCH" 45 | echo $NEW_TAG -------------------------------------------------------------------------------- /hrflow/core/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .validation import ( 4 | is_valid_extension, 5 | is_valid_filename, 6 | validate_key, 7 | validate_reference, 8 | ) 9 | 10 | 11 | def format_item_payload(item, provider_key, key, reference=None, email=None): 12 | provider = "source_key" if item == "profile" else "board_key" 13 | 14 | payload = {provider: validate_key("provider", provider_key)} 15 | if key: 16 | payload["key"] = validate_key("item", key) 17 | if reference: 18 | payload["reference"] = validate_reference(reference) 19 | if email: 20 | payload["profile_email"] = email 21 | 22 | return payload 23 | 24 | 25 | def get_files_from_dir(dir_path, is_recurcive): 26 | file_res = [] 27 | files_path = os.listdir(dir_path) 28 | 29 | for file_path in files_path: 30 | true_path = os.path.join(dir_path, file_path) 31 | if os.path.isdir(true_path) and is_recurcive: 32 | if is_valid_filename(true_path): 33 | file_res += get_files_from_dir(true_path, is_recurcive) 34 | continue 35 | if is_valid_extension(true_path): 36 | file_res.append(true_path) 37 | return file_res 38 | -------------------------------------------------------------------------------- /hrflow/profile/revealing.py: -------------------------------------------------------------------------------- 1 | from ..core import format_item_payload 2 | from ..core.rate_limit import rate_limiter 3 | from ..core.validation import validate_response 4 | 5 | 6 | class ProfileRevealing: 7 | """Manage revealing related profile calls.""" 8 | 9 | def __init__(self, api): 10 | """Init.""" 11 | self.client = api 12 | 13 | @rate_limiter 14 | def get(self, source_key=None, key=None, reference=None, email=None): 15 | """ 16 | Retrieve Parsing information. 17 | 18 | Args: 19 | source_key: 20 | source_key 21 | key: 22 | key 23 | reference: 24 | profile_reference 25 | email: 26 | profile_email 27 | 28 | Returns 29 | Get information 30 | 31 | """ 32 | query_params = format_item_payload("profile", source_key, key, reference, email) 33 | response = self.client.get("profile/revealing", query_params) 34 | return validate_response(response) 35 | -------------------------------------------------------------------------------- /hrflow/profile/attachment.py: -------------------------------------------------------------------------------- 1 | from ..core import format_item_payload 2 | from ..core.rate_limit import rate_limiter 3 | from ..core.validation import validate_response 4 | 5 | 6 | class ProfileAttachments: 7 | """Manage documents related profile calls.""" 8 | 9 | def __init__(self, api): 10 | """Init.""" 11 | self.client = api 12 | 13 | @rate_limiter 14 | def list(self, source_key, key=None, reference=None, email=None): 15 | """ 16 | Retrieve the interpretability information. 17 | 18 | Args: 19 | source_key: 20 | source_key 21 | key: 22 | key 23 | reference: 24 | profile_reference 25 | email: 26 | profile_email 27 | 28 | Returns 29 | Attachment information 30 | 31 | """ 32 | query_params = format_item_payload("profile", source_key, key, reference, email) 33 | response = self.client.get("profile/indexing/attachments", query_params) 34 | return validate_response(response) 35 | -------------------------------------------------------------------------------- /hrflow/text/imaging.py: -------------------------------------------------------------------------------- 1 | from ..core.rate_limit import rate_limiter 2 | from ..core.validation import validate_response 3 | 4 | 5 | class TextImaging: 6 | """Manage Imaging API related calls.""" 7 | 8 | def __init__(self, api): 9 | """Init.""" 10 | self.client = api 11 | 12 | @rate_limiter 13 | def post(self, text, width=256): 14 | """ 15 | This endpoint allows you to generate an image from a job description text. 16 | 17 | Args: 18 | text: 19 | Job text that describes the image to be generated. 20 | Ideally it should includes a "Job title". 21 | width: 22 | Width of the image to be generated. Default is 256. 23 | (The width and height of the image should be among 24 | the following pixel values : [256, 512, 1024 ]) 25 | Returns 26 | A public url to the generated image. 27 | 28 | """ 29 | payload = {"text": text, "width": width} 30 | response = self.client.post("text/imaging", json=payload) 31 | return validate_response(response) 32 | -------------------------------------------------------------------------------- /hrflow/text/parsing.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from ..core.rate_limit import rate_limiter 4 | from ..core.validation import validate_response 5 | 6 | 7 | class TextParsing: 8 | """Manage Text parsing related calls.""" 9 | 10 | def __init__(self, api): 11 | """Init.""" 12 | self.client = api 13 | 14 | @rate_limiter 15 | def post( 16 | self, text: t.Optional[str] = None, texts: t.Optional[t.List[str]] = None 17 | ) -> t.Dict[str, t.Any]: 18 | """ 19 | Parse a raw Text. Extract over 50 data point from any raw input text. 20 | 21 | Args: 22 | texts: 23 | Parse a list of texts. Each text can be: the full text 24 | of a Job, a Resume, a Profile, an experience, a Job and more. 25 | 26 | Returns: 27 | `/text/parsing` response 28 | """ 29 | 30 | if text is not None: 31 | if texts is not None: 32 | raise ValueError("Only one of text or texts must be provided.") 33 | else: 34 | payload = dict(text=text) 35 | else: 36 | if texts is None: 37 | raise ValueError("Either text or texts must be provided.") 38 | else: 39 | payload = dict(texts=texts) 40 | 41 | response = self.client.post("text/parsing", json=payload) 42 | 43 | return validate_response(response) 44 | -------------------------------------------------------------------------------- /hrflow/profile/embedding.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from ..core import format_item_payload 4 | from ..core.rate_limit import rate_limiter 5 | from ..core.validation import validate_response 6 | 7 | 8 | class ProfileEmbedding: 9 | """Manage embedding related profile calls.""" 10 | 11 | def __init__(self, api): 12 | """Init.""" 13 | self.client = api 14 | 15 | @rate_limiter 16 | def get(self, source_key, key=None, reference=None, email=None, fields={}): 17 | """ 18 | Retrieve the interpretability information. 19 | 20 | Args: 21 | source_key: 22 | source_key 23 | key: 24 | key 25 | reference: 26 | profile_reference 27 | email: 28 | profile_email 29 | fields: json object 30 | fields 31 | 32 | Returns 33 | interpretability information 34 | 35 | """ 36 | query_params = format_item_payload("profile", source_key, key, reference, email) 37 | if fields: 38 | query_params["fields"] = json.dumps(fields) 39 | response = self.client.get("profile/embedding", query_params) 40 | return validate_response(response) 41 | -------------------------------------------------------------------------------- /hrflow/profile/__init__.py: -------------------------------------------------------------------------------- 1 | """Profile related calls.""" 2 | 3 | from .asking import ProfileAsking 4 | from .attachment import ProfileAttachments 5 | from .embedding import ProfileEmbedding 6 | from .grading import ProfileGrading 7 | from .matching import ProfileMatching 8 | from .parsing import ProfileParsing 9 | from .reasoning import ProfileReasoning 10 | from .revealing import ProfileRevealing 11 | from .scoring import ProfileScoring 12 | from .searching import ProfileSearching 13 | from .storing import ProfileStoring 14 | from .unfolding import ProfileUnfolding 15 | 16 | 17 | class Profile(object): 18 | def __init__(self, client): 19 | """ 20 | Initialize Profile object with hrflow client. 21 | 22 | Args: 23 | client: hrflow client instance 24 | 25 | Returns 26 | Profile instance object. 27 | 28 | """ 29 | self.client = client 30 | self.asking = ProfileAsking(self.client) 31 | self.attachment = ProfileAttachments(self.client) 32 | self.parsing = ProfileParsing(self.client) 33 | self.storing = ProfileStoring(self.client) 34 | self.embedding = ProfileEmbedding(self.client) 35 | self.revealing = ProfileRevealing(self.client) 36 | self.scoring = ProfileScoring(self.client) 37 | self.searching = ProfileSearching(self.client) 38 | self.reasoning = ProfileReasoning(self.client) 39 | self.unfolding = ProfileUnfolding(self.client) 40 | self.matching = ProfileMatching(self.client) 41 | self.grading = ProfileGrading(self.client) 42 | -------------------------------------------------------------------------------- /hrflow/job/asking.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from ..core.rate_limit import rate_limiter 4 | from ..core.validation import ( 5 | KEY_REGEX, 6 | validate_key, 7 | validate_reference, 8 | validate_response, 9 | ) 10 | 11 | 12 | class JobAsking: 13 | def __init__(self, api): 14 | self.client = api 15 | 16 | @rate_limiter 17 | def get( 18 | self, 19 | board_key: str, 20 | questions: t.List[str], 21 | reference: t.Optional[str] = None, 22 | key: t.Optional[str] = None, 23 | ) -> t.Dict[str, t.Any]: 24 | """ 25 | Ask a question to a Job indexed in a Board. This endpoint allows asking a 26 | question based on a Job object. 27 | 28 | Args: 29 | board_key: 30 | The key of the Board associated to the job 31 | questions: 32 | Questions based on the queried job 33 | reference: 34 | The Job reference chosen by the customer 35 | key: 36 | The Job unique identifier 37 | 38 | Returns: 39 | `/job/asking` response 40 | """ 41 | 42 | params = dict( 43 | board_key=validate_key("Board", board_key, regex=KEY_REGEX), 44 | reference=validate_reference(reference), 45 | key=validate_key("Job", key, regex=KEY_REGEX), 46 | questions=questions, 47 | ) 48 | 49 | response = self.client.get("job/asking", query_params=params) 50 | 51 | return validate_response(response) 52 | -------------------------------------------------------------------------------- /hrflow/profile/asking.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from ..core.rate_limit import rate_limiter 4 | from ..core.validation import ( 5 | KEY_REGEX, 6 | validate_key, 7 | validate_reference, 8 | validate_response, 9 | ) 10 | 11 | 12 | class ProfileAsking: 13 | def __init__(self, api): 14 | self.client = api 15 | 16 | @rate_limiter 17 | def get( 18 | self, 19 | source_key: str, 20 | questions: t.List[str], 21 | reference: t.Optional[str] = None, 22 | key: t.Optional[str] = None, 23 | ) -> t.Dict[str, t.Any]: 24 | """ 25 | Ask a question to a Profile indexed in a Source. This endpoint allows asking a 26 | question based on a Profile object. 27 | 28 | Args: 29 | source_key: 30 | The key of the Source associated to the profile. 31 | questions: 32 | Question based on the queried profile. 33 | reference: str 34 | The Profile reference chosen by the customer. 35 | key: str 36 | The Profile unique identifier 37 | 38 | Returns: 39 | `/profile/asking` response 40 | """ 41 | 42 | params = dict( 43 | source_key=validate_key("Source", source_key, regex=KEY_REGEX), 44 | reference=validate_reference(reference), 45 | key=validate_key("Profile", key, regex=KEY_REGEX), 46 | questions=questions, 47 | ) 48 | 49 | response = self.client.get("profile/asking", query_params=params) 50 | 51 | return validate_response(response) 52 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "hrflow" 3 | version = "4.2.0" 4 | description = "Python hrflow.ai API package" 5 | authors = ["HrFlow.ai "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/hrflow/python-hrflow-api" 9 | documentation = "https://github.com/hrflow/python-hrflow-api" 10 | exclude = ["Makefile", ".pre-commit-config.yaml", "manifest.json", "tests", "poetry.lock"] 11 | packages = [{include = "hrflow"}] 12 | 13 | [tool.poetry.urls] 14 | "HrFlow.ai Documentation" = "https://developers.hrflow.ai" 15 | "Changelog" = "https://github.com/Riminder/python-hrflow-api/releases" 16 | 17 | [tool.poetry.dependencies] 18 | python = "^3.8.1" 19 | requests = "^2.31.0" 20 | tqdm = "^4.66.2" 21 | openpyxl = "^3.1.2" 22 | pydantic = "^1.10.8" 23 | 24 | [tool.poetry.group.dev.dependencies] 25 | pytest = "^7.4.3" 26 | python-dotenv = "^1.0.0" 27 | isort = "^5.13.1" 28 | black = "^23.12.0" 29 | flake8 = "^6.1.0" 30 | pytest-timestamper = "^0.0.9" 31 | notebook = "^7.1.1" 32 | 33 | [tool.black] 34 | line-length = 88 35 | target-version = ["py37", "py38", "py39", "py310"] 36 | preview = true 37 | 38 | [tool.isort] 39 | profile = "black" 40 | 41 | [tool.pytest.ini_options] 42 | markers = [ 43 | "archive", 44 | "asking", 45 | "auth", 46 | "editing", 47 | "embedding", 48 | "geocoding", 49 | "hawk", 50 | "imaging", 51 | "indexing", 52 | "job", 53 | "linking", 54 | "mozart", 55 | "ocr", 56 | "parsing", 57 | "parsing_file_async", 58 | "parsing_file_sync", 59 | "profile", 60 | "quicksilver", 61 | "rate_limit", 62 | "scoring", 63 | "searching", 64 | "tagging", 65 | "text", 66 | "unfolding" 67 | ] 68 | 69 | [build-system] 70 | requires = ["poetry-core"] 71 | build-backend = "poetry.core.masonry.api" 72 | -------------------------------------------------------------------------------- /hrflow/profile/unfolding.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from ..core.rate_limit import rate_limiter 4 | from ..core.validation import ( 5 | KEY_REGEX, 6 | validate_key, 7 | validate_reference, 8 | validate_response, 9 | ) 10 | 11 | 12 | class ProfileUnfolding: 13 | def __init__(self, api): 14 | self.client = api 15 | 16 | @rate_limiter 17 | def get( 18 | self, 19 | source_key: str, 20 | reference: t.Optional[str] = None, 21 | key: t.Optional[str] = None, 22 | max_steps: int = 1, 23 | job_text: t.Optional[str] = None, 24 | ) -> t.Dict[str, t.Any]: 25 | """ 26 | Unfold the career path of a Profile. This endpoint allows predicting the 27 | future experiences and educations of a profile. 28 | 29 | Args: 30 | source_key: 31 | The key of the Source associated to the profile. 32 | key: 33 | The Profile unique identifier. 34 | reference: 35 | The Profile reference chosen by the customer. 36 | max_steps: 37 | Number of predicted experiences to get into the target 38 | job position. 39 | job_text: 40 | Target job description 41 | 42 | Returns: 43 | `/profile/unholding` response 44 | """ 45 | 46 | params = dict( 47 | source_key=validate_key("Source", source_key, regex=KEY_REGEX), 48 | reference=validate_reference(reference), 49 | key=validate_key("Key", key, regex=KEY_REGEX), 50 | max_steps=max_steps, 51 | job_text=job_text, 52 | ) 53 | 54 | response = self.client.get("profile/unfolding", query_params=params) 55 | 56 | return validate_response(response) 57 | -------------------------------------------------------------------------------- /hrflow/board/__init__.py: -------------------------------------------------------------------------------- 1 | from ..core.rate_limit import rate_limiter 2 | from ..core.validation import ( 3 | ORDER_BY_VALUES, 4 | validate_key, 5 | validate_limit, 6 | validate_page, 7 | validate_response, 8 | validate_value, 9 | ) 10 | 11 | 12 | class Board(object): 13 | def __init__(self, client): 14 | self.client = client 15 | 16 | @rate_limiter 17 | def list(self, name=None, page=1, limit=30, sort_by="date", order_by="desc"): 18 | """ 19 | Search boards for given filters. 20 | 21 | Args: 22 | name: 23 | name 24 | page: 25 | page 26 | limit: 27 | limit 28 | sort_by: 29 | sort_by 30 | order_by: 31 | order_by 32 | 33 | Returns 34 | Result of source's search 35 | 36 | """ 37 | query_params = {} 38 | if name: 39 | query_params["name"] = name 40 | query_params["page"] = validate_page(page) 41 | query_params["limit"] = validate_limit(limit) 42 | query_params["sort_by"] = sort_by 43 | query_params["order_by"] = validate_value(order_by, ORDER_BY_VALUES, "order by") 44 | response = self.client.get("boards", query_params) 45 | return validate_response(response) 46 | 47 | @rate_limiter 48 | def get(self, key=None): 49 | """ 50 | Get source given a board key. 51 | 52 | Args: 53 | key: 54 | board_key 55 | Returns 56 | Board if exists 57 | 58 | """ 59 | query_params = {"key": validate_key("Board", key)} 60 | response = self.client.get("board", query_params) 61 | return validate_response(response) 62 | -------------------------------------------------------------------------------- /hrflow/source/__init__.py: -------------------------------------------------------------------------------- 1 | from ..core.rate_limit import rate_limiter 2 | from ..core.validation import ( 3 | ORDER_BY_VALUES, 4 | validate_key, 5 | validate_limit, 6 | validate_page, 7 | validate_response, 8 | validate_value, 9 | ) 10 | 11 | 12 | class Source(object): 13 | def __init__(self, client): 14 | self.client = client 15 | 16 | @rate_limiter 17 | def list(self, name=None, page=1, limit=30, sort_by="date", order_by="desc"): 18 | """ 19 | Search sources for given filters. 20 | 21 | Args: 22 | name: 23 | name 24 | page: 25 | page 26 | limit: 27 | limit 28 | sort_by: 29 | sort_by 30 | order_by: 31 | order_by 32 | 33 | Returns 34 | Result of source's search 35 | 36 | """ 37 | query_params = {} 38 | if name: 39 | query_params["name"] = name 40 | query_params["page"] = validate_page(page) 41 | query_params["limit"] = validate_limit(limit) 42 | query_params["sort_by"] = sort_by 43 | query_params["order_by"] = validate_value(order_by, ORDER_BY_VALUES, "order by") 44 | response = self.client.get("sources", query_params) 45 | return validate_response(response) 46 | 47 | @rate_limiter 48 | def get(self, key=None): 49 | """ 50 | Get source given a source id. 51 | 52 | Args: 53 | source_key: 54 | source_key 55 | Returns 56 | Source if exists 57 | 58 | """ 59 | query_params = {"key": validate_key("Source", key)} 60 | response = self.client.get("source", query_params) 61 | return validate_response(response) 62 | -------------------------------------------------------------------------------- /hrflow/job/searching.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from ..core.rate_limit import rate_limiter 4 | from ..core.validation import ( 5 | ORDER_BY_VALUES, 6 | SORT_BY_VALUES, 7 | STAGE_VALUES, 8 | validate_limit, 9 | validate_page, 10 | validate_provider_keys, 11 | validate_response, 12 | validate_value, 13 | ) 14 | 15 | 16 | class JobSearching: 17 | """Manage stage related profile calls.""" 18 | 19 | def __init__(self, api): 20 | """Init.""" 21 | self.client = api 22 | 23 | @rate_limiter 24 | def list( 25 | self, 26 | board_keys=None, 27 | stage=None, 28 | page=1, 29 | limit=30, 30 | sort_by="created_at", 31 | order_by=None, 32 | **kwargs, 33 | ): 34 | """ 35 | Retrieve the scoring information. 36 | 37 | Args: 38 | board_keys: 39 | board_keys 40 | stage: 41 | stage 42 | limit: (default to 30) 43 | number of fetched profiles/page 44 | page: REQUIRED default to 1 45 | number of the page associated to the pagination 46 | sort_by: 47 | order_by: 48 | 49 | Returns 50 | parsing information 51 | 52 | """ 53 | 54 | query_params = { 55 | "board_keys": json.dumps(validate_provider_keys(board_keys)), 56 | "stage": validate_value(stage, STAGE_VALUES), 57 | "limit": validate_limit(limit), 58 | "page": validate_page(page), 59 | "sort_by": validate_value(sort_by, SORT_BY_VALUES, "sort by"), 60 | "order_by": validate_value(order_by, ORDER_BY_VALUES, "order by"), 61 | } 62 | 63 | params = {**query_params, **kwargs} 64 | response = self.client.get("jobs/searching", params) 65 | return validate_response(response) 66 | -------------------------------------------------------------------------------- /hrflow/profile/searching.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from ..core.rate_limit import rate_limiter 4 | from ..core.validation import ( 5 | ORDER_BY_VALUES, 6 | SORT_BY_VALUES, 7 | STAGE_VALUES, 8 | validate_limit, 9 | validate_page, 10 | validate_provider_keys, 11 | validate_response, 12 | validate_value, 13 | ) 14 | 15 | 16 | class ProfileSearching: 17 | """Manage stage related profile calls.""" 18 | 19 | def __init__(self, api): 20 | """Init.""" 21 | self.client = api 22 | 23 | @rate_limiter 24 | def list( 25 | self, 26 | source_keys=None, 27 | stage=None, 28 | page=1, 29 | limit=30, 30 | sort_by="created_at", 31 | order_by=None, 32 | **kwargs, 33 | ): 34 | """ 35 | Retrieve the scoring information. 36 | 37 | Args: 38 | source_keys: 39 | source_keys 40 | stage: 41 | stage 42 | limit: (default to 30) 43 | number of fetched profiles/page 44 | page: REQUIRED default to 1 45 | number of the page associated to the pagination 46 | sort_by: 47 | order_by: 48 | 49 | Returns 50 | parsing information 51 | 52 | """ 53 | 54 | query_params = { 55 | "source_keys": json.dumps(validate_provider_keys(source_keys)), 56 | "stage": validate_value(stage, STAGE_VALUES, "stage"), 57 | "limit": validate_limit(limit), 58 | "page": validate_page(page), 59 | "sort_by": validate_value(sort_by, SORT_BY_VALUES, "sort by"), 60 | "order_by": validate_value(order_by, ORDER_BY_VALUES, "oder by"), 61 | } 62 | 63 | params = {**query_params, **kwargs} 64 | response = self.client.get("profiles/searching", params) 65 | return validate_response(response) 66 | -------------------------------------------------------------------------------- /hrflow/core/rate_limit.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from time import sleep, time 3 | 4 | DEFAULT_MAX_REQUESTS_PER_MINUTE = None 5 | DEFAULT_MIN_SLEEP_PER_REQUEST = 0 6 | SECONDS_IN_MINUTE = 60 7 | 8 | 9 | def rate_limiter(func): 10 | """ 11 | Decorator that applies rate limiting to a function. 12 | 13 | Parameters in the decorated function: 14 | max_requests_per_minute: The maximum number of requests that can be made 15 | in a minute. If None, there is no limit. 16 | min_sleep_per_request: The minimum time to wait between requests. 17 | 18 | Usage: 19 | >>> @rate_limiter() 20 | ... def my_function(param1, param2): 21 | ... pass 22 | ... my_function(1, 2, max_requests_per_minute=10, min_sleep_per_request=0.1) 23 | ... # The function will be called at most 10 times per minute 24 | ... # with at least 0.1 seconds between each call 25 | """ 26 | requests_per_minute = 0 27 | last_reset_time = time() 28 | 29 | @wraps(func) 30 | def wrapper(*args, **kwargs): 31 | max_requests_per_minute = kwargs.pop( 32 | "max_requests_per_minute", DEFAULT_MAX_REQUESTS_PER_MINUTE 33 | ) 34 | min_sleep_per_request = kwargs.pop( 35 | "min_sleep_per_request", DEFAULT_MIN_SLEEP_PER_REQUEST 36 | ) 37 | nonlocal requests_per_minute, last_reset_time 38 | 39 | current_time = time() 40 | elapsed_time = current_time - last_reset_time 41 | 42 | if elapsed_time < SECONDS_IN_MINUTE: 43 | requests_per_minute += 1 44 | if ( 45 | max_requests_per_minute is not None 46 | and requests_per_minute > max_requests_per_minute 47 | ): 48 | 49 | sleep(SECONDS_IN_MINUTE - elapsed_time) 50 | requests_per_minute = 0 51 | last_reset_time = time() 52 | else: 53 | requests_per_minute = 0 54 | last_reset_time = current_time 55 | 56 | sleep(min_sleep_per_request) 57 | return func(*args, **kwargs) 58 | 59 | return wrapper 60 | -------------------------------------------------------------------------------- /hrflow/utils/storing.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from tqdm import tqdm 4 | 5 | 6 | def get_all_profiles( 7 | client: "Hrflow", # noqa: F821 8 | source_key: str, 9 | show_progress: bool = False, 10 | ) -> t.List[t.Dict[str, t.Any]]: 11 | """ 12 | Retrieve all profiles from a source. 13 | 14 | Args: 15 | client: 16 | hrflow client 17 | source_key: 18 | source_key 19 | show_progress: 20 | Show the progress bar 21 | 22 | Returns 23 | : 24 | List of profiles 25 | """ 26 | max_page = client.profile.storing.list(source_keys=[source_key])["meta"]["maxPage"] 27 | profile_list = [] 28 | page_range = range(1, max_page + 1) 29 | if show_progress: 30 | page_range = tqdm(page_range, "Retrieving profiles") 31 | for page in page_range: 32 | profile_list += client.profile.storing.list( 33 | source_keys=[source_key], page=page, return_profile=True 34 | )["data"] 35 | 36 | return profile_list 37 | 38 | 39 | def get_all_jobs( 40 | client: "Hrflow", # noqa: F821 41 | board_key: str, 42 | show_progress: bool = False, 43 | ) -> t.List[t.Dict[str, t.Any]]: 44 | """ 45 | Retrieve all jobs from a board. 46 | 47 | Args: 48 | client: 49 | hrflow client 50 | board_key: 51 | board_key 52 | show_progress: 53 | Show the progress bar 54 | 55 | Returns 56 | : 57 | List of jobs 58 | """ 59 | max_page = client.job.storing.list(board_keys=[board_key])["meta"]["maxPage"] 60 | job_list = [] 61 | page_range = range(1, max_page + 1) 62 | if show_progress: 63 | page_range = tqdm(page_range, "Retrieving jobs") 64 | for page in page_range: 65 | job_list += client.job.storing.list( 66 | board_keys=[board_key], page=page, return_job=True 67 | )["data"] 68 | 69 | return job_list 70 | -------------------------------------------------------------------------------- /hrflow/utils/searching.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from ..schemas import HrFlowProfile, ProfileInfo 4 | 5 | 6 | def is_valid_info_for_searching(info: ProfileInfo) -> bool: 7 | """ 8 | Check if the info part of a profile is valid for searching 9 | 10 | Based on the following schemas https://developers.hrflow.ai/docs/profiles-searching 11 | 12 | Args: 13 | info: 14 | Info part of the profile 15 | """ 16 | if not isinstance(info, ProfileInfo): 17 | raise ValueError("info must be a ProfileInfo object") 18 | 19 | first_name_score = 1 if info.first_name else 0 20 | last_name_score = 1 if info.last_name else 0 21 | phone_score = 1 if info.phone else 0 22 | date_birth_score = 1 if info.date_birth else 0 23 | gender_score = 1 if info.gender else 0 24 | summary_score = 1 if info.summary else 0 25 | urls_score = 1 if info.urls else 0 26 | location_score = 1 if info.location else 0 27 | 28 | info_score = ( 29 | first_name_score 30 | + last_name_score 31 | + phone_score 32 | + date_birth_score 33 | + gender_score 34 | + summary_score 35 | + urls_score 36 | + location_score 37 | ) 38 | info_score = info_score / 8 39 | has_person = info.first_name and info.last_name 40 | 41 | return info.email or has_person or info_score >= 0.5 42 | 43 | 44 | def is_valid_for_searching( 45 | profile: t.Union[t.Dict, HrFlowProfile], 46 | ) -> bool: 47 | """ 48 | Check if a profile is valid for searching 49 | 50 | Based on the following schemas https://developers.hrflow.ai/docs/profiles-searching 51 | 52 | Args: 53 | client: 54 | Hrflow client 55 | profile: or 56 | Profile to check 57 | Return: 58 | True if the profile is valid for searching, 59 | False otherwise 60 | """ 61 | if isinstance(profile, dict): 62 | profile = HrFlowProfile.parse_obj(profile) 63 | 64 | if not isinstance(profile, HrFlowProfile): 65 | raise ValueError("profile must be a dict or a HrFlowProfile object") 66 | 67 | return is_valid_info_for_searching(profile.info) and bool(profile.text) 68 | -------------------------------------------------------------------------------- /hrflow/utils/scoring.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from ..schemas import Education, Experience, HrFlowProfile 4 | from .searching import is_valid_for_searching 5 | 6 | 7 | def is_valid_experiences_for_scoring( 8 | experience_list: t.Optional[t.List[Experience]], 9 | ) -> bool: 10 | """ 11 | Check if a list of experiences is valid for scoring 12 | 13 | Args: 14 | experience_list: 15 | List of experiences to check 16 | Return: 17 | True if the list of experiences is valid for 18 | scoring, False otherwise 19 | """ 20 | for experience in experience_list: 21 | if experience.title: 22 | return True 23 | 24 | return False 25 | 26 | 27 | def is_valid_educations_for_scoring( 28 | education_list: t.Optional[t.List[Education]], 29 | ) -> bool: 30 | """ 31 | Check if a list of educations is valid for scoring 32 | 33 | Args: 34 | education_list: 35 | List of educations to check 36 | Return: 37 | True if the list of educations is valid for 38 | scoring, False otherwise 39 | """ 40 | for education in education_list: 41 | if education.title or education.school: 42 | return True 43 | return False 44 | 45 | 46 | def is_valid_for_scoring( 47 | profile: t.Union[t.Dict, HrFlowProfile], 48 | ) -> bool: 49 | """ 50 | Check if a profile is valid for scoring 51 | 52 | Based on the following schemas https://developers.hrflow.ai/docs/profiles-scoring 53 | 54 | Args: 55 | client: 56 | Hrflow client 57 | profile: or 58 | Profile to check 59 | Return: 60 | True if the profile is valid for scoring, 61 | False otherwise 62 | """ 63 | if isinstance(profile, dict): 64 | profile = HrFlowProfile.parse_obj(profile) 65 | 66 | if not isinstance(profile, HrFlowProfile): 67 | raise ValueError("profile must be a dict or a HrFlowProfile object") 68 | 69 | return is_valid_for_searching(profile) and ( 70 | is_valid_experiences_for_scoring(profile.experiences) 71 | or is_valid_educations_for_scoring(profile.educations) 72 | or bool(profile.info.summary) 73 | or bool(profile.skills) 74 | or bool(profile.tasks) 75 | ) 76 | -------------------------------------------------------------------------------- /hrflow/profile/scoring.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from ..core.rate_limit import rate_limiter 4 | from ..core.validation import ( 5 | ORDER_BY_VALUES, 6 | SORT_BY_VALUES, 7 | STAGE_VALUES, 8 | validate_key, 9 | validate_limit, 10 | validate_page, 11 | validate_provider_keys, 12 | validate_response, 13 | validate_value, 14 | ) 15 | 16 | 17 | class ProfileScoring: 18 | """Manage stage related profile calls.""" 19 | 20 | def __init__(self, api): 21 | """Init.""" 22 | self.client = api 23 | 24 | @rate_limiter 25 | def list( 26 | self, 27 | source_keys=None, 28 | board_key=None, 29 | job_key=None, 30 | use_agent=1, 31 | agent_key=None, 32 | stage=None, 33 | page=1, 34 | limit=30, 35 | sort_by="created_at", 36 | order_by=None, 37 | **kwargs, 38 | ): 39 | """ 40 | Retrieve the scoring information. 41 | 42 | Args: 43 | source_keys: 44 | source_keys 45 | board_key: 46 | board_key 47 | job_key: 48 | job_key 49 | use_agent: 50 | use_agent 51 | agent_key: 52 | agent_key 53 | stage: 54 | stage 55 | limit: (default to 30) 56 | number of fetched profiles/page 57 | page: REQUIRED default to 1 58 | number of the page associated to the pagination 59 | sort_by: 60 | order_by: 61 | 62 | Returns 63 | parsing information 64 | 65 | """ 66 | 67 | query_params = { 68 | "source_keys": json.dumps(validate_provider_keys(source_keys)), 69 | "board_key": validate_key("Board", board_key), 70 | "job_key": validate_key("Job", job_key), 71 | "use_agent": use_agent, 72 | "agent_key": validate_key("Agent", agent_key), 73 | "stage": validate_value(stage, STAGE_VALUES, "stage"), 74 | "limit": validate_limit(limit), 75 | "page": validate_page(page), 76 | "sort_by": validate_value(sort_by, SORT_BY_VALUES, "sort by"), 77 | "order_by": validate_value(order_by, ORDER_BY_VALUES, "order by"), 78 | } 79 | 80 | params = {**query_params, **kwargs} 81 | response = self.client.get("profiles/scoring", params) 82 | return validate_response(response) 83 | -------------------------------------------------------------------------------- /hrflow/job/scoring.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from ..core.rate_limit import rate_limiter 4 | from ..core.validation import ( 5 | ORDER_BY_VALUES, 6 | SORT_BY_VALUES, 7 | STAGE_VALUES, 8 | validate_key, 9 | validate_limit, 10 | validate_page, 11 | validate_provider_keys, 12 | validate_response, 13 | validate_value, 14 | ) 15 | 16 | 17 | class JobScoring: 18 | """Manage job related profile calls.""" 19 | 20 | def __init__(self, api): 21 | """Init.""" 22 | self.client = api 23 | 24 | @rate_limiter 25 | def list( 26 | self, 27 | board_keys=None, 28 | source_key=None, 29 | profile_key=None, 30 | use_agent=None, 31 | agent_key=None, 32 | stage=None, 33 | page=1, 34 | limit=30, 35 | sort_by="created_at", 36 | order_by=None, 37 | **kwargs, 38 | ): 39 | """ 40 | Retrieve the scoring information. 41 | 42 | Args: 43 | board_keys: 44 | board_keys 45 | source_key: 46 | source_key 47 | profile_key: 48 | profile_key 49 | agent_key: 50 | agent_key 51 | use_agent: 52 | use_agent 53 | stage: 54 | stage 55 | limit: (default to 30) 56 | number of fetched profiles/page 57 | page: REQUIRED default to 1 58 | number of the page associated to the pagination 59 | sort_by: 60 | order_by: 61 | 62 | Returns 63 | parsing information 64 | 65 | """ 66 | 67 | query_params = { 68 | "board_keys": json.dumps(validate_provider_keys(board_keys)), 69 | "source_key": validate_key("Source", source_key), 70 | "profile_key": validate_key("Profile", profile_key), 71 | "use_agent": use_agent, 72 | "agent_key": validate_key("Agent", agent_key), 73 | "stage": validate_value(stage, STAGE_VALUES, "stage"), 74 | "limit": validate_limit(limit), 75 | "page": validate_page(page), 76 | "sort_by": validate_value(sort_by, SORT_BY_VALUES, "sort by"), 77 | "order_by": validate_value(order_by, ORDER_BY_VALUES, "order by"), 78 | } 79 | 80 | params = {**query_params, **kwargs} 81 | response = self.client.get("jobs/scoring", params) 82 | return validate_response(response) 83 | -------------------------------------------------------------------------------- /hrflow/profile/grading.py: -------------------------------------------------------------------------------- 1 | import json 2 | import typing as t 3 | 4 | from ..core.rate_limit import rate_limiter 5 | from ..core.validation import ( 6 | KEY_REGEX, 7 | ORDER_BY_VALUES, 8 | SORT_BY_VALUES, 9 | validate_key, 10 | validate_limit, 11 | validate_page, 12 | validate_provider_keys, 13 | validate_reference, 14 | validate_response, 15 | validate_value, 16 | ) 17 | 18 | 19 | class ProfileGrading: 20 | def __init__(self, api): 21 | """Initialize the ProfileGrading class with the provided API client.""" 22 | self.client = api 23 | 24 | @rate_limiter 25 | def get( 26 | self, 27 | algorithm_key: str, 28 | source_key: str, 29 | board_key: str, 30 | profile_key: t.Optional[str] = None, 31 | profile_reference: t.Optional[str] = None, 32 | job_key: t.Optional[str] = None, 33 | job_reference: t.Optional[str] = None, 34 | ): 35 | """ 36 | 💾 Grade a Profile indexed in a Source for a Job 37 | (https://api.hrflow.ai/v1/profile/grading). 38 | 39 | Args: 40 | algorithm_key: 41 | The key of the grading algorithm to use. 42 | Refer to the documentation: https://developers.hrflow.ai/reference/grade-a-profile-indexed-in-a-source-for-a-job 43 | for all possible values. 44 | source_key: 45 | The key of the Source where the profile to grade is indexed. 46 | board_key: 47 | The key of the Board where the job to grade to is indexed. 48 | profile_key: 49 | (Optional) The Profile unique identifier. 50 | profile_reference: 51 | (Optional) The Profile reference chosen by the customer. 52 | job_key: 53 | (Optional) The Job unique identifier. 54 | job_reference: 55 | (Optional) The Job reference chosen by the customer. 56 | 57 | Returns: 58 | The grading information for the profile, based on the specified job. 59 | 60 | """ 61 | query_params = { 62 | "algorithm_key": algorithm_key, 63 | "source_key": validate_key("Source", source_key, regex=KEY_REGEX), 64 | "profile_key": validate_key("Key", profile_key, regex=KEY_REGEX), 65 | "profile_reference": validate_reference(profile_reference), 66 | "board_key": validate_key("Board", board_key, regex=KEY_REGEX), 67 | "job_key": validate_key("Key", job_key, regex=KEY_REGEX), 68 | "job_reference": validate_reference(job_reference), 69 | } 70 | 71 | response = self.client.get("profile/grading", query_params) 72 | return validate_response(response) 73 | -------------------------------------------------------------------------------- /hrflow/core/validation.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | KEY_REGEX = r"^[0-9a-f]{40}$" 5 | STAGE_VALUES = [None, "new", "yes", "later", "no"] 6 | SORT_BY_VALUES = [ 7 | "created_at", 8 | "updated_at", 9 | "location", 10 | "location_experience", 11 | "location_education", 12 | "searching", 13 | "scoring", 14 | ] 15 | ORDER_BY_VALUES = [None, "desc", "asc"] 16 | VALID_EXTENSIONS = [ 17 | ".pdf", 18 | ".png", 19 | ".jpg", 20 | ".jpeg", 21 | ".bmp", 22 | ".doc", 23 | ".docx", 24 | ".rtf", 25 | ".dotx", 26 | ".odt", 27 | ".odp", 28 | ".ppt", 29 | ".pptx", 30 | ".rtf", 31 | ".msg", 32 | ] 33 | INVALID_FILENAME = [".", ".."] 34 | 35 | 36 | def validate_boolean(name, value): 37 | """ 38 | This function validates the fact that the value is a boolean. If not, it raises a 39 | TypeError. 40 | If the given value is a string that can be converted to a boolean, it converts it. 41 | :param name: The name of the variable to validate 42 | :param value: The value to validate 43 | :return: The value 44 | """ 45 | if not isinstance(value, bool) or ( 46 | isinstance(value, str) and value not in ["0", "1"] 47 | ): 48 | raise TypeError( 49 | "{name} must be boolean not {value}".format(name=name, value=value) 50 | ) 51 | 52 | return value if isinstance(value, bool) else bool(int(value)) 53 | 54 | 55 | def validate_key(obj, value, regex=None): 56 | if not isinstance(value, str) and value is not None: 57 | raise TypeError(obj + " key must be string") 58 | 59 | if regex and not bool(re.match(regex, value)): 60 | raise ValueError(f"{obj} key must match {regex}") 61 | 62 | return value 63 | 64 | 65 | def validate_value(value, values, message="value"): 66 | if value not in values: 67 | raise ValueError("{} must be in {}".format(message, str(values))) 68 | return value 69 | 70 | 71 | def validate_reference(value): 72 | if value is None: 73 | return value 74 | if not isinstance(value, str) and value is not None: 75 | raise TypeError("reference must be string not {}".format(value)) 76 | 77 | return value 78 | 79 | 80 | def validate_page(value): 81 | if not isinstance(value, int): 82 | raise TypeError("page must be 'int'") 83 | 84 | return value 85 | 86 | 87 | def validate_limit(value): 88 | if not isinstance(value, int): 89 | raise TypeError("limit must be 'int'") 90 | 91 | return value 92 | 93 | 94 | def validate_provider_keys(value): 95 | if not value or not all(isinstance(elt, str) for elt in value): 96 | raise TypeError("provider_ids must contain list of strings") 97 | return value 98 | 99 | 100 | def is_valid_extension(file_path): 101 | ext = os.path.splitext(file_path)[1] 102 | if not ext: 103 | return False 104 | return ext in VALID_EXTENSIONS or ext.lower() in VALID_EXTENSIONS 105 | 106 | 107 | def is_valid_filename(file_path): 108 | name = os.path.basename(file_path) 109 | return name not in INVALID_FILENAME 110 | 111 | 112 | def validate_response(response): 113 | if response.headers["Content-Type"] != "application/json": 114 | return { 115 | "code": response.status_code, 116 | "message": "A generic error occurred on the server", 117 | } 118 | return response.json() 119 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Env 2 | .env 3 | 4 | # Datas 5 | *.png 6 | *.jpg 7 | *.jpeg 8 | *.pdf 9 | *.docx 10 | *.h5 11 | credentials 12 | credentials_seg 13 | test/* 14 | test_assets/* 15 | tests/assets 16 | .htpasswd 17 | test.ipynb 18 | 19 | docker/dependencies/libs/* 20 | 21 | demo_app/imgs/* 22 | !demo_app/imgs/.empty 23 | demo_app/jsons/* 24 | !demo_app/jsons/.empty 25 | demo_app/uploads/* 26 | !demo_app/uploads/.empty 27 | demo_app/pictures/* 28 | !demo_app/pictures/image-not-found.png 29 | 30 | ExtPage* 31 | ExtDocument* 32 | 33 | test/*.ipynb 34 | scripts/data/* 35 | tests/collections/* 36 | tests/collections/test_template.postman_collection.json 37 | 38 | weights/* 39 | !weights/__init__.py 40 | extraction/test/* 41 | extraction/_dirname/* 42 | #json outputs 43 | tests/parsing_test_profile.json 44 | tests/parsing_test_profile_parsed_elements.json 45 | 46 | #json outputs 47 | tests/parsing_test_profile.json 48 | tests/parsing_test_profile_parsed_elements.json 49 | 50 | # IDE 51 | .idea/* 52 | .vscode/* 53 | # Created by https://www.gitignore.io/api/python,osx,django 54 | 55 | ### Python ### 56 | # Byte-compiled / optimized / DLL files 57 | __pycache__/ 58 | *.py[cod] 59 | *$py.class 60 | 61 | # C extensions 62 | *.so 63 | 64 | # Distribution / packaging 65 | .Python 66 | env/ 67 | build/ 68 | develop-eggs/ 69 | dist/ 70 | downloads/ 71 | eggs/ 72 | .eggs/ 73 | lib/ 74 | lib64/ 75 | parts/ 76 | sdist/ 77 | var/ 78 | *.egg-info/ 79 | .installed.cfg 80 | *.egg 81 | 82 | # PyInstaller 83 | # Usually these files are written by a python script from a template 84 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 85 | *.manifest 86 | *.spec 87 | 88 | # Installer logs 89 | pip-log.txt 90 | pip-delete-this-directory.txt 91 | 92 | # Unit test / coverage reports 93 | htmlcov/ 94 | .tox/ 95 | .coverage 96 | .coverage.* 97 | .cache 98 | nosetests.xml 99 | coverage.xml 100 | *,cover 101 | .hypothesis/ 102 | 103 | debuggingAPIs.ipynb 104 | # Translations 105 | *.mo 106 | *.pot 107 | 108 | # Django stuff: 109 | *.log 110 | local_settings.py 111 | 112 | # Flask stuff: 113 | instance/ 114 | .webassets-cache 115 | 116 | # Scrapy stuff: 117 | .scrapy 118 | 119 | # Sphinx documentation 120 | docs/_build/ 121 | 122 | # PyBuilder 123 | target/ 124 | 125 | # IPython Notebook 126 | .ipynb_checkpoints 127 | 128 | # pyenv 129 | .python-version 130 | 131 | # celery beat schedule file 132 | celerybeat-schedule 133 | 134 | # dotenv 135 | api/dotenv/* 136 | !api/dotenv/.env_dev 137 | apps/.app_current_config 138 | 139 | # virtualenv 140 | .venv/ 141 | venv/ 142 | ENV/ 143 | virtualenv/ 144 | nano.save 145 | # Spyder project settings 146 | .spyderproject 147 | 148 | # Rope project settings 149 | .ropeproject 150 | 151 | 152 | ### OSX ### 153 | *.DS_Store 154 | .AppleDouble 155 | .LSOverride 156 | 157 | # Icon must end with two \r 158 | Icon 159 | 160 | 161 | # Thumbnails 162 | ._* 163 | 164 | # Files that might appear in the root of a volume 165 | .DocumentRevisions-V100 166 | .fseventsd 167 | .Spotlight-V100 168 | .TemporaryItems 169 | .Trashes 170 | .VolumeIcon.icns 171 | .com.apple.timemachine.donotpresent 172 | 173 | # Directories potentially created on remote AFP share 174 | .AppleDB 175 | .AppleDesktop 176 | Network Trash Folder 177 | Temporary Items 178 | .apdisk 179 | 180 | 181 | ### Django ### 182 | *.log 183 | *.pot 184 | *.pyc 185 | __pycache__/ 186 | local_settings.py 187 | db.sqlite3 188 | media 189 | 190 | deploy/*.zip 191 | 192 | 193 | # Pyre type checker 194 | .pyre/ 195 | 196 | .envrc -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from requests import codes as http_codes 3 | 4 | from hrflow import Hrflow 5 | 6 | from .utils.schemas import AuthResponse 7 | from .utils.tools import _var_from_env_get 8 | 9 | 10 | @pytest.mark.auth 11 | def test_valid_all(): 12 | model = AuthResponse.parse_obj( 13 | Hrflow( 14 | api_secret=_var_from_env_get("HRFLOW_API_KEY"), 15 | api_user=_var_from_env_get("HRFLOW_USER_EMAIL"), 16 | ).auth.get() 17 | ) 18 | assert model.code == http_codes.ok 19 | 20 | 21 | @pytest.mark.auth 22 | def test_valid_read(): 23 | model = AuthResponse.parse_obj( 24 | Hrflow( 25 | api_secret=_var_from_env_get("HRFLOW_API_KEY_READ"), 26 | api_user=_var_from_env_get("HRFLOW_USER_EMAIL"), 27 | ).auth.get() 28 | ) 29 | assert model.code == http_codes.ok 30 | 31 | 32 | @pytest.mark.skip( 33 | reason="write permission key, for now, can not be used for a GET request" 34 | ) 35 | @pytest.mark.auth 36 | def test_valid_write(): 37 | model = AuthResponse.parse_obj( 38 | Hrflow( 39 | api_secret=_var_from_env_get("HRFLOW_API_KEY_WRITE"), 40 | api_user=_var_from_env_get("HRFLOW_USER_EMAIL"), 41 | ).auth.get() 42 | ) 43 | assert model.code == http_codes.ok 44 | 45 | 46 | # All the keys below, in raw text, are mock 47 | 48 | 49 | @pytest.mark.auth 50 | def test_invalid_valid_askw(): 51 | model = AuthResponse.parse_obj( 52 | Hrflow( 53 | api_secret="askw_d86bb249fff3ac66765f04d43c611675", 54 | api_user=_var_from_env_get("HRFLOW_USER_EMAIL"), 55 | ).auth.get() 56 | ) 57 | assert model.code == http_codes.unauthorized 58 | 59 | 60 | @pytest.mark.auth 61 | def test_api_secret_regex_42(): 62 | with pytest.raises(ValueError): 63 | Hrflow( 64 | api_secret="42", api_user=_var_from_env_get("HRFLOW_USER_EMAIL") 65 | ).auth.get() 66 | 67 | 68 | @pytest.mark.auth 69 | def test_api_secret_regex_not_hex(): 70 | with pytest.raises(ValueError): 71 | Hrflow( 72 | api_secret="ask_xa62249b693f2b4cc29524624abfc659", 73 | api_user=_var_from_env_get("HRFLOW_USER_EMAIL"), 74 | ).auth.get() 75 | 76 | 77 | @pytest.mark.auth 78 | def test_api_secret_regex_basic_key(): 79 | with pytest.raises(ValueError): 80 | Hrflow( 81 | api_secret="b2631028fab36393d8bf05ca143b75e3424ea78e", 82 | api_user=_var_from_env_get("HRFLOW_USER_EMAIL"), 83 | ).auth.get() 84 | 85 | 86 | @pytest.mark.auth 87 | def test_api_secret_regex_valid_with_padding_start(): 88 | with pytest.raises(ValueError): 89 | Hrflow( 90 | api_secret=" ask_d89a3523b8b5c34b24e8831239bb6ba0", 91 | api_user=_var_from_env_get("HRFLOW_USER_EMAIL"), 92 | ).auth.get() 93 | 94 | 95 | @pytest.mark.auth 96 | def test_api_secret_regex_valid_with_padding_end(): 97 | with pytest.raises(ValueError): 98 | Hrflow( 99 | api_secret="ask_7f24675fbaadfaeb1e9ea57201b1b92c ", 100 | api_user=_var_from_env_get("HRFLOW_USER_EMAIL"), 101 | ).auth.get() 102 | 103 | 104 | @pytest.mark.auth 105 | def test_api_secret_too_short(): 106 | with pytest.raises(ValueError): 107 | Hrflow( 108 | api_secret=_var_from_env_get("HRFLOW_API_KEY")[:-1], 109 | api_user=_var_from_env_get("HRFLOW_USER_EMAIL"), 110 | ).auth.get() 111 | 112 | 113 | @pytest.mark.auth 114 | def test_api_secret_too_long(): 115 | with pytest.raises(ValueError): 116 | Hrflow( 117 | api_secret=_var_from_env_get("HRFLOW_API_KEY") + "f", 118 | api_user=_var_from_env_get("HRFLOW_USER_EMAIL"), 119 | ).auth.get() 120 | -------------------------------------------------------------------------------- /hrflow/text/tagging.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from ..core.rate_limit import rate_limiter 4 | from ..core.validation import validate_response 5 | 6 | 7 | class TextTagging: 8 | """Manage tagging related calls.""" 9 | 10 | def __init__(self, api): 11 | """Init.""" 12 | self.client = api 13 | 14 | @rate_limiter 15 | def post( 16 | self, 17 | algorithm_key: str, 18 | text: t.Optional[str] = None, 19 | texts: t.Optional[t.List[str]] = None, 20 | context: t.Optional[str] = None, 21 | labels: t.Optional[t.List[str]] = None, 22 | top_n: t.Optional[int] = 1, 23 | output_lang: t.Optional[str] = "en", 24 | ) -> t.Dict[str, t.Any]: 25 | """ 26 | Tag a Text. Predict most likely tags for a text with our library of AI 27 | algorithms. 28 | 29 | Args: 30 | algorithm_key: 31 | AI tagging algorithm you want to apply to 32 | the input text. Six taggers have been released 33 | through the Tagging API. We are actively working 34 | on bringing out more taggers. 35 | Here is a list of all the currently available 36 | taggers (beaware that the list is subject to 37 | change refer to developers.hrflow.ai for the 38 | latest list): 39 | - tagger-rome-family: Grand domaines of job the French ROME 40 | - tagger-rome-subfamily: Domaines of job the French ROME 41 | - tagger-rome-category: Metiers of job the French ROME 42 | - tagger-rome-jobtitle: Appellations of job the French ROME 43 | - tagger-hrflow-skills: Skills referential defined by HrFlow.ai 44 | - tagger-hrflow-labels: User defined labels, if any 45 | 46 | texts: 47 | Tag a list of texts. Each text can be: the 48 | full text of a Job, a Resume, a Profile, an 49 | experience, a Job and more. 50 | 51 | context: 52 | A context for given labels if 53 | algorithm_key="tagger-hrflow-labels". 54 | 55 | labels: 56 | List of output tags if 57 | algorithm_key="tagger-hrflow-labels". 58 | 59 | top_n: 60 | Number of predicted tags that will be returned. 61 | 62 | output_lang: 63 | Language of the returned tags. 64 | 65 | Returns: 66 | `/text/tagging` response 67 | """ 68 | payload = dict( 69 | algorithm_key=algorithm_key, 70 | context=context, 71 | labels=labels, 72 | output_lang=output_lang, 73 | top_n=top_n, 74 | ) 75 | 76 | if texts is None and text is not None: 77 | payload["text"] = text 78 | elif text is None and texts is not None: 79 | payload["texts"] = texts 80 | elif text is None and texts is None: 81 | raise ValueError("Either text or texts must be provided.") 82 | else: 83 | raise ValueError("Only one of text or texts must be provided.") 84 | 85 | response = self.client.post("text/tagging", json=payload) 86 | return validate_response(response) 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | hrflow 4 | 5 |

6 |

7 | HrFlow.ai Python SDK 8 |

9 |

10 | Unify, Unleash, Automate Talent & Workforce Data 11 |

12 | 13 |

14 | 15 | Test 16 | 17 | 18 | 19 | 20 | 21 | Release 22 | 23 | 24 | Slack 25 | 26 | 27 | YouTube Channel Views 28 | 29 | 30 | License 31 | 32 |

33 | 34 | **HrFlow.ai** is the first System of Intelligence for Talent & Workforce Data, enabling leading AI-powered Talent & Workforce experiences. Our suite of APIs provide a suite of AI solutions to unleash clean, structured, normalized, updated and analyzed Talent Data with state-of-the-art seven AI modules. 35 | 36 | Furthermore, HrFlow.ai breaks data silos and creates a single source of truth (SSOT) for Talent Data. With our API connectors, businesses can sync data between their tools in milliseconds and build customizable workflows that meet their business logic. 37 | 38 | Our suite of APIs includes **Parsing API, Tagging API, Embedding API, Searching API, Scoring API, Imaging API**, and **OEM Widgets**. With our Automation Studio and AI Studio, businesses can utilize AI-powered user interfaces and **low-code/no-code** automations to create advanced custom user experiences. 39 | 40 | ## 💡 Help 41 | 42 | See [documentation](Documentation.md) for more examples. 43 | 44 | ## 🛠️ Installation 45 | 46 | Install using `pip install -U hrflow` or `conda install hrflow -c conda-forge`. 47 | 48 |

49 | 50 | hrflow 51 | 52 |

53 | 54 | 55 | ## 🪄 Quick start 56 | 57 | ```py 58 | from hrflow import Hrflow 59 | client = Hrflow(api_secret="YOUR_API_KEY", api_user="YOU_USER_EMAIL") 60 | 61 | # read file from directory (in binary mode) 62 | with open("path_to_file.pdf", "rb") as profile_file: 63 | # Parse it using this method without reference: 64 | response = client.profile.parsing.add_file( 65 | source_key="INSERT_THE_TARGET_SOURCE_KEY", 66 | profile_file=file, 67 | sync_parsing=1, # This is to invoke real time parsing 68 | tags=[{"name": "application_reference", "value": "TS_X12345"}], # Attach an application tag to the profile to be parsed 69 | ) 70 | ``` 71 | 72 | 73 | ## 📎 Resources 74 | - [Slack](https://hrflow-club.slack.com/) for a speedy exchange of ideas between the Community and the HrFlow.ai team 75 | - [HrFlow.ai Academy](https://www.youtube.com/@hrflow.aiacademy9534) on Youtube for videos on how to get started with HrFlow.ai 76 | - [Updates page](https://updates.hrflow.ai/) to keep you informed about our product releases 77 | - [Documentation](https://developers.hrflow.ai/reference/authentication) to provide information on HrFlow.ai features 78 | - [Our Roadmap](https://roadmap.hrflow.ai/) to show upcoming features or request new ones 79 | -------------------------------------------------------------------------------- /hrflow/utils/evaluation/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import typing as t 3 | import urllib 4 | from io import BytesIO 5 | 6 | from openpyxl import load_workbook 7 | from openpyxl.workbook.workbook import Workbook 8 | 9 | from ..storing import get_all_jobs, get_all_profiles 10 | from .job import TEMPLATE_URL as JOB_TEMPLATE_URL 11 | from .job import fill_work_sheet as fill_job_work_sheet 12 | from .job import parsing_evaluator as job_parsing_evaluator 13 | from .profile import TEMPLATE_URL as PROFILE_TEMPLATE_URL 14 | from .profile import fill_work_sheet as fill_profile_work_sheet 15 | from .profile import parsing_evaluator as profile_parsing_evaluator 16 | 17 | STATISTICS_SHEET_NAME = "1. Statistics" 18 | 19 | 20 | def load_workbook_from_url(url: str) -> Workbook: 21 | """ 22 | Load an excel file from a url 23 | 24 | Args: 25 | url: 26 | The url of the excel file 27 | 28 | Returns: 29 | 30 | The loaded workbook 31 | """ 32 | file = urllib.request.urlopen(url).read() 33 | return load_workbook(filename=BytesIO(file)) 34 | 35 | 36 | def prepare_report_path(path: str) -> str: 37 | """ 38 | Prepare the report path 39 | 40 | Args: 41 | path: 42 | The path of the report 43 | """ 44 | if os.path.isdir(path): 45 | return os.path.join(path, "parsing_evaluation.xlsx") 46 | if not path.endswith(".xlsx"): 47 | return f"{path}.xlsx" 48 | return path 49 | 50 | 51 | def generate_parsing_evaluation_report( 52 | client: "Hrflow", # noqa: F821 53 | report_path: str, 54 | source_key: t.Optional[str] = None, 55 | board_key: t.Optional[str] = None, 56 | show_progress: bool = False, 57 | ): 58 | """ 59 | Generate a parsing evaluation report 60 | 61 | If you want to generate a parsing evaluation report for jobs, you must 62 | provide the board_key. 63 | If you want to generate a parsing evaluation report for profiles, you must 64 | provide the source_key. 65 | 66 | board_key and source_key are optional string, you must provide only one of them. 67 | 68 | Args: 69 | client: 70 | The client to use 71 | source_key: 72 | The source key where the profiles are 73 | board_key: 74 | The board key where the jobs are 75 | report_path: 76 | The path of the report 77 | This can be a already existing directory where 78 | the report will be saved as parsing_evaluation.xlsx 79 | This can be directly the path of the report. 80 | If the path is not an excel file (xlsx), 81 | the report will be saved as {path}.xlsx 82 | show_progress: 83 | Show the progress bar 84 | """ 85 | 86 | if not source_key and not board_key: 87 | raise ValueError("You must provide either source_key or board_key") 88 | if source_key and board_key: 89 | raise ValueError("You must provide only one of source_key or board_key") 90 | 91 | if source_key: 92 | profile_list = get_all_profiles(client, source_key, show_progress) 93 | evaluation_list = profile_parsing_evaluator(profile_list, show_progress) 94 | 95 | work_book = load_workbook_from_url(PROFILE_TEMPLATE_URL) 96 | work_sheet = work_book[STATISTICS_SHEET_NAME] 97 | fill_profile_work_sheet(work_sheet, evaluation_list, show_progress) 98 | else: 99 | assert board_key is not None 100 | job_list = get_all_jobs(client, board_key, show_progress) 101 | evaluation_list = job_parsing_evaluator(job_list, show_progress) 102 | 103 | work_book = load_workbook_from_url(JOB_TEMPLATE_URL) 104 | work_sheet = work_book[STATISTICS_SHEET_NAME] 105 | fill_job_work_sheet(work_sheet, evaluation_list, show_progress) 106 | 107 | report_path = prepare_report_path(report_path) 108 | work_book.save(report_path) 109 | work_book.close() 110 | -------------------------------------------------------------------------------- /hrflow/job/matching.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from ..core.rate_limit import rate_limiter 4 | from ..core.validation import ( 5 | KEY_REGEX, 6 | ORDER_BY_VALUES, 7 | SORT_BY_VALUES, 8 | validate_key, 9 | validate_limit, 10 | validate_page, 11 | validate_provider_keys, 12 | validate_reference, 13 | validate_response, 14 | validate_value, 15 | ) 16 | 17 | 18 | class JobMatching: 19 | def __init__(self, api): 20 | """Init.""" 21 | self.client = api 22 | 23 | @rate_limiter 24 | def list( 25 | self, 26 | board_key, 27 | job_key=None, 28 | job_reference=None, 29 | board_keys=None, 30 | page=1, 31 | limit=30, 32 | sort_by="created_at", 33 | order_by=None, 34 | created_at_min=None, 35 | created_at_max=None, 36 | **kwargs, 37 | ): 38 | """ 39 | 💾 Match Jobs indexed in Boards to a Job 40 | (https://api.hrflow.ai/v1/jobs/matching). 41 | 42 | Args: 43 | board_key: 44 | The key of the Board in which the job is indexed. 45 | job_key: 46 | The key of a specific job to macth with. 47 | job_reference: 48 | The reference of a specific job to macth with. 49 | board_keys: 50 | A list of keys for multiple Boards of profiles to be matched with the specific job. 51 | Example : ["xxx", "yyy", "zzz"] 52 | limit: (default to 30) 53 | number of fetched jobs/page 54 | page: REQUIRED default to 1 55 | number of the page associated to the pagination 56 | sort_by: 57 | order_by: 58 | created_at_min: 59 | The minimum date of creation of the targeted Jobs. 60 | Format: "YYYY-MM-DD". 61 | created_at_max: 62 | The maximum date of creation of the targeted Jobs. 63 | Format: "YYYY-MM-DD". 64 | 65 | Returns: 66 | Match the job identified by job_key or job_reference 67 | and board_key with all jobs in the boards identified by keys in board_keys list. 68 | Response examples: 69 | - Success response: 70 | { 71 | "code": 200, # response code 72 | "message": "Job Matching results", # response message 73 | "meta" : { 74 | 'page': 1, # current page 75 | 'maxPage': 5, # max page in the paginated response 76 | 'count': 2, # number of jobs in the current page 77 | 'total': 10 # total number of jobs retrieved 78 | }, 79 | "data": { # list of jobs objects 80 | "predictions": [[]], 81 | "jobs": [ 82 | { 83 | "key": "xxx", 84 | "reference": "xxx", 85 | ... 86 | }, 87 | ... 88 | ] 89 | } 90 | } 91 | - Error response: (if the board_key is not valid) 92 | { 93 | "code": 400, 94 | "message": "Invalid parameters. Unable to find object: source" 95 | } 96 | """ 97 | 98 | query_params = { 99 | "board_key": validate_key("Board", board_key, regex=KEY_REGEX), 100 | "job_key": validate_key("Key", job_key, regex=KEY_REGEX), 101 | "job_reference": validate_reference(job_reference), 102 | "board_keys": json.dumps(validate_provider_keys(board_keys)), 103 | "limit": validate_limit(limit), 104 | "page": validate_page(page), 105 | "sort_by": validate_value(sort_by, SORT_BY_VALUES, "sort by"), 106 | "order_by": validate_value(order_by, ORDER_BY_VALUES, "order by"), 107 | "created_at_min": created_at_min, # TODO validate dates format 108 | "created_at_max": created_at_max, # TODO validate dates format 109 | } 110 | 111 | params = {**query_params, **kwargs} 112 | response = self.client.get("jobs/matching", params) 113 | return validate_response(response) 114 | -------------------------------------------------------------------------------- /hrflow/profile/matching.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from ..core.rate_limit import rate_limiter 4 | from ..core.validation import ( 5 | KEY_REGEX, 6 | ORDER_BY_VALUES, 7 | SORT_BY_VALUES, 8 | validate_key, 9 | validate_limit, 10 | validate_page, 11 | validate_provider_keys, 12 | validate_reference, 13 | validate_response, 14 | validate_value, 15 | ) 16 | 17 | 18 | class ProfileMatching: 19 | def __init__(self, api): 20 | """Initialize the ProfileMatching class with the provided API client.""" 21 | self.client = api 22 | 23 | @rate_limiter 24 | def list( 25 | self, 26 | source_key, 27 | profile_key=None, 28 | profile_reference=None, 29 | source_keys=None, 30 | page=1, 31 | limit=30, 32 | sort_by="created_at", 33 | order_by=None, 34 | created_at_min=None, 35 | created_at_max=None, 36 | **kwargs, 37 | ): 38 | """ 39 | 💾 Match Profils indexed in Sources to a Profile 40 | (https://api.hrflow.ai/v1/profils/matching). 41 | 42 | Args: 43 | source_key: 44 | The key of the Source in which the profile is indexed. 45 | profile_key: (Optional) 46 | The key of a specific profile to macth with. 47 | profile_reference: (Optional) 48 | The reference of a specific profile to macth with. 49 | source_keys: (Optional) 50 | A list of keys for multiple Sources of profiles to be matched with the profile. 51 | page: (default to 1) 52 | The page number for pagination. 53 | limit: (default to 30) 54 | Number of profiles to fetch per page. 55 | sort_by: (default to "created_at") 56 | The field to sort by. 57 | order_by: (Optional) 58 | The order of sorting, either 'asc' or 'desc'. 59 | created_at_min: (Optional) 60 | The minimum creation date of the profiles in format "YYYY-MM-DD". 61 | created_at_max: (Optional) 62 | The maximum creation date of the profiles in format "YYYY-MM-DD". 63 | 64 | Returns: 65 | Match the profile identified by profile_key or profile_reference 66 | and source_key with all profiles in the sources identified by keys in source_keys list. 67 | 68 | Response examples: 69 | - Success response: 70 | { 71 | "code": 200, # response code 72 | "message": "Profile Matching results", # response message 73 | "meta": { 74 | 'page': 1, # current page 75 | 'maxPage': 5, # max page in the paginated response 76 | 'count': 2, # number of profiles in the current page 77 | 'total': 10 # total number of profiles retrieved 78 | }, 79 | "data": { # list of profile objects 80 | "predictions": [[]], 81 | "profiles": [ 82 | { 83 | "key": "xxx", 84 | "reference": "xxx", 85 | ... 86 | }, 87 | ... 88 | ] 89 | } 90 | } 91 | - Error response: (if the source_key is not valid) 92 | { 93 | "code": 400, 94 | "message": "Invalid parameters. Unable to find object: source" 95 | } 96 | """ 97 | 98 | query_params = { 99 | "source_key": validate_key("Source", source_key, regex=KEY_REGEX), 100 | "profile_key": validate_key("Key", profile_key, regex=KEY_REGEX), 101 | "profile_reference": validate_reference(profile_reference), 102 | "source_keys": json.dumps(validate_provider_keys(source_keys)), 103 | "limit": validate_limit(limit), 104 | "page": validate_page(page), 105 | "sort_by": validate_value(sort_by, SORT_BY_VALUES, "sort by"), 106 | "order_by": validate_value(order_by, ORDER_BY_VALUES, "order by"), 107 | "created_at_min": created_at_min, # TODO validate dates format 108 | "created_at_max": created_at_max, # TODO validate dates format 109 | } 110 | 111 | params = {**query_params, **kwargs} 112 | response = self.client.get("profiles/matching", params) 113 | return validate_response(response) 114 | -------------------------------------------------------------------------------- /tests/test_rate_limit.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | 3 | import pytest 4 | 5 | from hrflow.core.rate_limit import rate_limiter 6 | 7 | 8 | @pytest.mark.rate_limit 9 | def test_rate_limit_no_params(): 10 | i = 0 11 | 12 | @rate_limiter 13 | def increment(): 14 | nonlocal i 15 | i += 1 16 | 17 | start_time = time() 18 | for round in range(5): 19 | increment() 20 | assert i == round + 1, ( 21 | "The sub function wrapper with rate limiting must have", 22 | "a side effect, but the result is inconsistent with what it should be", 23 | ) 24 | 25 | end_time = time() 26 | duration = end_time - start_time 27 | 28 | assert duration < 1, "5 calls should be less than 1 second" 29 | 30 | 31 | @pytest.mark.rate_limit 32 | def test_rate_limit_no_params_and_with_function_params(): 33 | i = 0 34 | memory = None 35 | 36 | @rate_limiter 37 | def increment(round: int): 38 | nonlocal i 39 | nonlocal memory 40 | 41 | i += 1 42 | memory = round 43 | 44 | start_time = time() 45 | for round in range(5): 46 | increment(round) 47 | assert i == round + 1, ( 48 | "The sub function wrapper with rate limiting must have", 49 | "a side effect, but the result is inconsistent with what it should be", 50 | ) 51 | assert memory == round, ( 52 | "The sub function wrapper with rate limiting must", 53 | "have a side effect, but the result is inconsistent with what it should be", 54 | ) 55 | 56 | end_time = time() 57 | duration = end_time - start_time 58 | 59 | assert duration < 1, "5 calls should be less than 1 second" 60 | 61 | 62 | @pytest.mark.rate_limit 63 | def test_rate_limit_sleep_per_req_and_with_function_params(): 64 | min_sleep_per_request = 1 # second(s) 65 | delta_duration = 0.1 66 | i = 0 67 | memory = None 68 | 69 | @rate_limiter 70 | def increment(round: int): 71 | nonlocal i 72 | nonlocal memory 73 | 74 | i += 1 75 | memory = round 76 | 77 | global_start_time = time() 78 | round_count = 5 79 | for round in range(round_count): 80 | round_start_time = time() 81 | increment(round, min_sleep_per_request=min_sleep_per_request) 82 | assert i == round + 1, ( 83 | "The sub function wrapper with rate limiting must have", 84 | "a side effect, but the result is inconsistent with what it should be", 85 | ) 86 | assert memory == round, ( 87 | "The sub function wrapper with rate limiting must", 88 | "have a side effect, but the result is inconsistent with what it should be", 89 | ) 90 | round_duration = time() - round_start_time 91 | assert ( 92 | round_duration + delta_duration >= min_sleep_per_request 93 | ), "function call must be more than {min_sleep_per_request} second" 94 | 95 | end_time = time() 96 | global_duration = end_time - global_start_time 97 | 98 | assert ( 99 | global_duration + delta_duration >= min_sleep_per_request * round_count 100 | ), "5 calls should be more than 5 seconds" 101 | 102 | 103 | @pytest.mark.rate_limit 104 | def test_rate_limit_with_rpm(): 105 | SECONDS_IN_MINUTE = 60 106 | max_requests_per_minute = 5 # second(s) 107 | num_requests = 8 108 | delta_duration = 0.1 109 | i = 0 110 | 111 | @rate_limiter 112 | def increment(): 113 | # less than 0.1 second function 114 | nonlocal i 115 | i += 1 116 | 117 | for round in range(num_requests): 118 | print(round) 119 | round_start_time = time() 120 | increment(max_requests_per_minute=max_requests_per_minute) 121 | assert i == round + 1, ( 122 | "The sub function wrapper with rate limiting must have", 123 | "a side effect, but the result is inconsistent with what it should be", 124 | ) 125 | 126 | round_duration = time() - round_start_time 127 | if round != 0 and round % max_requests_per_minute == 0: 128 | assert ( 129 | round_duration + delta_duration >= SECONDS_IN_MINUTE 130 | ), f"unexpected more than {max_requests_per_minute} req per minute" 131 | else: 132 | assert ( 133 | round_duration <= delta_duration 134 | ), f"function call must be less than {delta_duration} second(s)" 135 | 136 | 137 | @pytest.mark.rate_limit 138 | def test_rate_limit_with_rpm_and_sleep_per_req(): 139 | SECONDS_IN_MINUTE = 60 140 | min_sleep_per_request = 1 # second(s) 141 | max_requests_per_minute = 5 # second(s) 142 | num_requests = 8 143 | delta_duration = 0.1 144 | i = 0 145 | 146 | @rate_limiter 147 | def increment(): 148 | # less than 0.1 second function 149 | nonlocal i 150 | i += 1 151 | 152 | for round in range(num_requests): 153 | print(round) 154 | round_start_time = time() 155 | increment( 156 | max_requests_per_minute=max_requests_per_minute, 157 | min_sleep_per_request=min_sleep_per_request, 158 | ) 159 | assert i == round + 1, ( 160 | "The sub function wrapper with rate limiting must have", 161 | "a side effect, but the result is inconsistent with what it should be", 162 | ) 163 | 164 | round_duration = time() - round_start_time 165 | if round != 0 and round % max_requests_per_minute == 0: 166 | normilized_duration = ( 167 | round_duration 168 | + delta_duration 169 | + min_sleep_per_request * max_requests_per_minute 170 | ) 171 | assert ( 172 | normilized_duration >= SECONDS_IN_MINUTE 173 | ), f"unexpected more than {max_requests_per_minute} req per minute" 174 | else: 175 | assert ( 176 | round_duration + delta_duration >= min_sleep_per_request 177 | ), "function call must be more than {min_sleep_per_request} second" 178 | -------------------------------------------------------------------------------- /hrflow/rating/__init__.py: -------------------------------------------------------------------------------- 1 | from ..core.rate_limit import rate_limiter 2 | from ..core.validation import validate_response 3 | 4 | 5 | class Rating: 6 | def __init__(self, client): 7 | self.client = client 8 | 9 | @rate_limiter 10 | def get( 11 | self, 12 | role, 13 | source_keys=None, 14 | source_key=None, 15 | profile_key=None, 16 | profile_reference=None, 17 | board_keys=None, 18 | board_key=None, 19 | job_key=None, 20 | job_reference=None, 21 | return_profile=False, 22 | return_author=False, 23 | page=1, 24 | limit=30, 25 | order_by="desc", 26 | sort_by="scoring", 27 | created_at_min=None, 28 | created_at_max=None, 29 | location_lat=None, 30 | location_lon=None, 31 | location_distance=None, 32 | use_location_address=None, 33 | use_location_experience=None, 34 | use_location_education=None, 35 | experiences_duration_min=None, 36 | experiences_duration_max=None, 37 | educations_duration_min=None, 38 | educations_duration_max=None, 39 | ): 40 | """ 41 | This endpoint allows you to retrieve the list of ratings. 42 | Visit : https://developers.hrflow.ai/reference for more information. 43 | 44 | Retrieve Ratings Associated with a Specific Profile or Job 45 | - To filter by a specific profile, include the parameters (source_key, 46 | profile_key//profile_reference) and leave source_keys empty. 47 | - To filter by a specific job, include the parameters (board_key, 48 | job_key//job_reference) and leave board_keys empty. 49 | 50 | Retrieve Ratings Associated with a Specific List of Profiles or Jobs 51 | - To filter ratings based on a list of profiles within a list of sources, 52 | include the parameter source_keys and leave (source_key, 53 | profile_key//profile_reference) empty. 54 | - To filter ratings based on a list of jobs within a list of boards, 55 | include the parameter board_keys and leave (board_key, 56 | job_key//job_reference) empty. 57 | """ 58 | args = locals() 59 | params = {} 60 | 61 | for arg, value in args.items(): 62 | if value is not None and arg != "self": 63 | params[arg] = value 64 | 65 | response = self.client.get(resource_endpoint="ratings", query_params=params) 66 | 67 | return validate_response(response) 68 | 69 | @rate_limiter 70 | def post( 71 | self, 72 | score, 73 | role, 74 | board_key, 75 | source_key, 76 | job_key=None, 77 | job_reference=None, 78 | profile_key=None, 79 | profile_reference=None, 80 | author_email=None, 81 | comment=None, 82 | created_at=None, 83 | ): 84 | """ 85 | This endpoint allows you to rate a Profile (resp. a Job) for Job (resp. a 86 | Profile) 87 | as a recruiter (resp. a candidate) with a score between 0 and 1. 88 | Visit : https://developers.hrflow.ai/reference for more information. 89 | 90 | The job_key and the job_reference cannot be null at the same time in the 91 | Request Parameters. 92 | The same for the profile_key and profile_reference. 93 | 94 | Args: 95 | score: 96 | The score is an evaluation fit between 0 to 1 . 97 | If you're using stars in your system please use 98 | the following conversion: 99 | 5 stars = 1.0 , 4 stars=0.8 , 3 stars=0.6, 2 100 | stars=0.4, 1 star= 0.2 . 101 | role: 102 | Role of the user rating the job role in 103 | {recruiter, candidate, employee, manager}. 104 | board_key: 105 | The key of Board attached to the given Job. 106 | source_key: 107 | The key of Source attached to the given Profile. 108 | job_key: 109 | job identifier (key) 110 | job_reference: 111 | The Job reference chosen by the customer or an 112 | external system. 113 | If you use the job_key you do not need to specify 114 | the job_reference and vice versa. 115 | profile_key: 116 | profile identifier (key) 117 | profile_reference: 118 | The Profile reference chosen by the customer or an 119 | external system. 120 | If you use the profile_key you do not need to 121 | specify the profile_reference and vice versa. 122 | author_email: 123 | author email 124 | comment: 125 | comment 126 | created_at: 127 | ISO Date of the rating. 128 | Format : yyyy-MM-dd'T'HH:mm:ss.SSSXXX — for 129 | example, "2000-10-31T01:30:00.000-05:00" 130 | It associates a creation date to the profile (ie: 131 | this can be for example the original date of the 132 | application of the profile). 133 | If not provided the creation date will be now by 134 | default. 135 | """ 136 | 137 | args = locals() 138 | body = {} 139 | 140 | for arg, value in args.items(): 141 | if value is not None and arg != "self": 142 | body[arg] = value 143 | 144 | # Underlying resource : POST /rating 145 | response = self.client.post(resource_endpoint="rating", json=body) 146 | return validate_response(response) 147 | -------------------------------------------------------------------------------- /hrflow/webhook/__init__.py: -------------------------------------------------------------------------------- 1 | """Webhook support.""" 2 | 3 | import hashlib 4 | import hmac 5 | import inspect 6 | import json 7 | import sys 8 | 9 | from . import base64Wrapper as base64W 10 | from . import bytesutils, hmacutils 11 | 12 | EVENT_PROFILE_PARSE_SUCCESS = "profile.parse.success" 13 | EVENT_PROFILE_PARSE_ERROR = "profile.parse.error" 14 | EVENT_PROFILE_SCORE_SUCCESS = "profile.score.success" 15 | EVENT_PROFILE_SCORE_ERROR = "profile.score.error" 16 | EVENT_JOB_TRAIN_SUCCESS = "job.train.success" 17 | EVENT_JOB_TRAIN_ERROR = "job.train.error" 18 | EVENT_JOB_TRAIN_START = "job.train.start" 19 | EVENT_JOB_SCORE_SUCCESS = "job.score.success" 20 | EVENT_JOB_SCORE_ERROR = "job.score.error" 21 | EVENT_JOB_SCORE_START = "job.score.start" 22 | ACTION_STAGE_SUCCESS = "action.stage.success" 23 | ACTION_STAGE_ERROR = "action.stage.error" 24 | ACTION_RATING_SUCCESS = "action.rating.success" 25 | ACTION_RATING_ERROR = "action.rating.error" 26 | 27 | 28 | SIGNATURE_HEADER = "HTTP-HRFLOW-SIGNATURE" 29 | 30 | 31 | class Webhook(object): 32 | """Class that handles Webhooks.""" 33 | 34 | def __init__(self, client): 35 | """Init.""" 36 | self.client = client 37 | self.handlers = { 38 | EVENT_PROFILE_PARSE_SUCCESS: None, 39 | EVENT_PROFILE_PARSE_ERROR: None, 40 | EVENT_PROFILE_SCORE_SUCCESS: None, 41 | EVENT_PROFILE_SCORE_ERROR: None, 42 | EVENT_JOB_TRAIN_SUCCESS: None, 43 | EVENT_JOB_TRAIN_ERROR: None, 44 | EVENT_JOB_TRAIN_START: None, 45 | EVENT_JOB_SCORE_SUCCESS: None, 46 | EVENT_JOB_SCORE_ERROR: None, 47 | EVENT_JOB_SCORE_START: None, 48 | ACTION_STAGE_SUCCESS: None, 49 | ACTION_STAGE_ERROR: None, 50 | ACTION_RATING_SUCCESS: None, 51 | ACTION_RATING_ERROR: None, 52 | } 53 | 54 | def check(self, url, type): 55 | """ 56 | Get response from api for POST webhook/check. 57 | 58 | Args: 59 | url: 60 | url id 61 | type: 62 | profile id 63 | 64 | Returns 65 | Webhook information 66 | 67 | """ 68 | data = {} 69 | data["url"] = url 70 | data["type"] = type 71 | response = self.client.post("webhook/check", json=data) 72 | return response.json() 73 | 74 | def test(self): 75 | """Get response from api for POST webhook/check.""" 76 | response = self.client.post("webhook/test") 77 | return response.json() 78 | 79 | def setHandler(self, event_name, callback): 80 | """Set an handler for given event.""" 81 | if event_name not in self.handlers: 82 | raise ValueError("{} is not a valid event".format(event_name)) 83 | if callable(event_name): 84 | raise TypeError("{} is not callable".format(callback)) 85 | self.handlers[event_name] = callback 86 | 87 | def isHandlerPresent(self, event_name): 88 | """Check if an event has an handler.""" 89 | if event_name not in self.handlers: 90 | raise ValueError("{} is not a valid event".format(event_name)) 91 | return self.handlers[event_name] is not None 92 | 93 | def removeHandler(self, event_name): 94 | """Remove handler for given event.""" 95 | if event_name not in self.handlers: 96 | raise ValueError("{} is not a valid event".format(event_name)) 97 | self.handlers[event_name] = None 98 | 99 | def _strtr(self, inp, fr, to): 100 | res = "" 101 | for c in inp: 102 | for idx, c_to_replace in enumerate(fr): 103 | if c == c_to_replace and idx < len(to): 104 | c = to[idx] 105 | res = res + c 106 | return res 107 | 108 | def _get_signature_header(self, signature_header, request_headers): 109 | if signature_header is not None: 110 | return signature_header 111 | if SIGNATURE_HEADER in request_headers: 112 | return request_headers[SIGNATURE_HEADER] 113 | raise ValueError("Error: No {} given".format(SIGNATURE_HEADER)) 114 | 115 | def _get_fct_number_of_arg(self, fct): 116 | """Get the number of argument of a fuction.""" 117 | py_version = sys.version_info[0] 118 | if py_version >= 3: 119 | return len(inspect.signature(fct).parameters) 120 | return len(inspect.getargspec(fct)[0]) 121 | 122 | def handle(self, request_headers={}, signature_header=None): 123 | """Handle request.""" 124 | if self.client.webhook_secret is None: 125 | raise ValueError("Error: no webhook secret.") 126 | encoded_header = self._get_signature_header(signature_header, request_headers) 127 | decoded_request = self._decode_request(encoded_header) 128 | if "type" not in decoded_request: 129 | raise ValueError("Error invalid request: no type field found.") 130 | handler = self._getHandlerForEvent(decoded_request["type"]) 131 | if handler is None: 132 | return 133 | if self._get_fct_number_of_arg(handler) == 1: 134 | handler(decoded_request) 135 | return 136 | handler(decoded_request, decoded_request["type"]) 137 | 138 | def _base64Urldecode(self, inp): 139 | inp = self._strtr(inp, "-_", "+/") 140 | byte_inp = base64W.decodebytes(bytesutils.strtobytes(inp, "ascii")) 141 | return byte_inp.decode("ascii") 142 | 143 | def _is_signature_valid(self, signature, payload): 144 | utf8_payload = bytesutils.strtobytes(payload, "utf8") 145 | utf8_wb_secret = bytesutils.strtobytes(self.client.webhook_secret, "utf8") 146 | hasher = hmac.new(utf8_wb_secret, utf8_payload, hashlib.sha256) 147 | exp_sign_digest = hasher.hexdigest() 148 | 149 | return hmacutils.compare_digest(exp_sign_digest, signature) 150 | 151 | def _decode_request(self, encoded_request): 152 | tmp = encoded_request.split(".", 2) 153 | if len(tmp) < 2: 154 | raise ValueError( 155 | "Error invalid request. Maybe it's not the 'HTTP-HRFLOW-SIGNATURE'" 156 | " field" 157 | ) 158 | encoded_sign = tmp[0] 159 | payload = tmp[1] 160 | sign = self._base64Urldecode(encoded_sign) 161 | data = self._base64Urldecode(payload) 162 | if not self._is_signature_valid(sign, data): 163 | raise ValueError("Error: invalid signature.") 164 | return json.loads(data) 165 | 166 | def _getHandlerForEvent(self, event_name): 167 | if event_name not in self.handlers: 168 | raise ValueError("{} is not a valid event".format(event_name)) 169 | handler = self.handlers[event_name] 170 | return handler 171 | -------------------------------------------------------------------------------- /hrflow/tracking/__init__.py: -------------------------------------------------------------------------------- 1 | from ..core.rate_limit import rate_limiter 2 | from ..core.validation import validate_response 3 | 4 | 5 | class Tracking: 6 | def __init__(self, client): 7 | self.client = client 8 | 9 | @rate_limiter 10 | def get( 11 | self, 12 | role, 13 | actions=None, 14 | source_keys=None, 15 | source_key=None, 16 | profile_key=None, 17 | profile_reference=None, 18 | board_keys=None, 19 | board_key=None, 20 | job_key=None, 21 | job_reference=None, 22 | return_profile=False, 23 | return_author=False, 24 | page=1, 25 | limit=30, 26 | order_by="desc", 27 | sort_by="scoring", 28 | created_at_min=None, 29 | created_at_max=None, 30 | location_lat=None, 31 | location_lon=None, 32 | location_distance=None, 33 | use_location_address=None, 34 | use_location_experience=None, 35 | use_location_education=None, 36 | experiences_duration_min=None, 37 | experiences_duration_max=None, 38 | educations_duration_min=None, 39 | educations_duration_max=None, 40 | ): 41 | """ 42 | This endpoint allows you to retrieve the list of trackings. 43 | Visit : https://developers.hrflow.ai/reference for more information. 44 | 45 | Retrieve Trackings Associated with a Specific Profile or Job 46 | - To filter by a specific profile, include the parameters (source_key, 47 | profile_key//profile_reference) and leave source_keys empty. 48 | - To filter by a specific job, include the parameters (board_key, 49 | job_key//job_reference) and leave board_keys empty. 50 | Retrieve Trackings Associated with a Specific List of Profiles or Jobs 51 | - To filter ratings based on a list of profiles within a list of sources, 52 | include the parameter source_keys and leave (source_key, 53 | profile_key//profile_reference) empty. 54 | - To filter ratings based on a list of jobs within a list of boards, 55 | include the parameter board_keys and leave (board_key, 56 | job_key//job_reference) empty. 57 | """ 58 | args = locals() # get all the arguments (local variables) of the function 59 | params = {} 60 | 61 | for arg, value in args.items(): 62 | if value is not None and arg != "self": 63 | params[arg] = value 64 | 65 | # Underlying resource : GET /trackings 66 | response = self.client.get(resource_endpoint="trackings", query_params=params) 67 | 68 | return validate_response(response) 69 | 70 | @rate_limiter 71 | def post( 72 | self, 73 | action, 74 | role, 75 | board_key, 76 | source_key, 77 | job_key=None, 78 | job_reference=None, 79 | profile_key=None, 80 | profile_reference=None, 81 | author_email=None, 82 | comment=None, 83 | created_at=None, 84 | ): 85 | """ 86 | This endpoint allows you to track a Profile (resp. a Job) for Job (resp. a 87 | Profile) as a recruiter 88 | (resp. a candidate) with a specific action 89 | 90 | Visit : https://developers.hrflow.ai/reference for more information. 91 | 92 | Note : The job_key and the job_reference cannot be null at the same time in 93 | the Request Parameters. 94 | The same for the profile_key and profile_reference . 95 | 96 | Args: 97 | action: 98 | The 'action' refers to a unique identifier for a 99 | profile or job stage. 100 | This can be a specific stage ID within a CRM, 101 | ATS, or Job site. 102 | Examples of such stages include "view," "apply," 103 | "hire," or any other stage relevant to your system. 104 | 105 | role: 106 | Role of the user rating the job (role: recruiter, 107 | candidate, employee, manager). 108 | board_key: 109 | The key of Board attached to the given Job. 110 | source_key: 111 | The key of Source attached to the given Profile. 112 | job_key: 113 | The Job's unique identifier. 114 | job_reference: 115 | The Job's reference chosen by the customer / 116 | external system. 117 | If you use the job_key you do not need to specify 118 | the job_reference and vice versa. 119 | profile_key: 120 | The Profile's unique identifier. 121 | profile_reference: 122 | The Profile's reference chosen by the customer / 123 | external system. 124 | If you use the profile_key you do not need to 125 | specify the profile_reference and vice versa. 126 | author_email: 127 | Email of the HrFlow.ai user who rated the profile 128 | for the job. 129 | comment: 130 | Comment explaining the reason behind the score. 131 | created_at: 132 | ISO Date of the rating. 133 | Format : yyyy-MM-dd'T'HH:mm:ss.SSSXXX — for 134 | example, "2000-10-31T01:30:00.000-05:00" 135 | It associates a creation date to the profile (ie: 136 | this can be for example the original date of the 137 | application of the profile). 138 | If not provided the creation date will be now by 139 | default. 140 | 141 | """ 142 | args = locals() 143 | body = {} 144 | 145 | for arg, value in args.items(): 146 | if value is not None and arg != "self": 147 | body[arg] = value 148 | 149 | # Underlying resource : POST /tracking 150 | response = self.client.post(resource_endpoint="tracking", json=body) 151 | return validate_response(response) 152 | -------------------------------------------------------------------------------- /hrflow/hrflow.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import requests as req 4 | 5 | from .auth import Auth 6 | from .board import Board 7 | from .job import Job 8 | from .profile import Profile 9 | from .rating import Rating 10 | from .source import Source 11 | from .text import Text 12 | from .tracking import Tracking 13 | from .webhook import Webhook 14 | 15 | CLIENT_API_URL = "https://api.hrflow.ai/v1/" 16 | 17 | 18 | class Hrflow(object): 19 | """client api wrapper client.""" 20 | 21 | def __init__( 22 | self, 23 | api_url=CLIENT_API_URL, 24 | api_secret=None, 25 | api_user=None, 26 | webhook_secret=None, 27 | ): 28 | """ 29 | Hrflow client. This class is the main entry point to the Hrflow API. 30 | 31 | Args: 32 | api_url: 33 | The API URL. Defaults to https://api.hrflow.ai/v1/ 34 | 35 | api_secret: 36 | The API secret key. You can find it in your 37 | Hrflow.ai account. 38 | 39 | api_user: 40 | The API user email. You can find it in your 41 | Hrflow.ai account. 42 | 43 | webhook_secret: 44 | 45 | Returns 46 | Hrflow client object 47 | """ 48 | self.api_url = api_url 49 | self.auth_header = {"X-API-KEY": api_secret, "X-USER-EMAIL": api_user} 50 | self.webhook_secret = webhook_secret 51 | self.auth = Auth(self) 52 | self.job = Job(self) 53 | self.profile = Profile(self) 54 | self.text = Text(self) 55 | self.webhooks = Webhook(self) 56 | self.source = Source(self) 57 | self.board = Board(self) 58 | self.tracking = Tracking(self) 59 | self.rating = Rating(self) 60 | 61 | def _create_request_url(self, resource_url): 62 | return "{api_endpoint}{resource_url}".format( 63 | api_endpoint=self.api_url, resource_url=resource_url 64 | ) 65 | 66 | def _fill_headers(self, header, base={}): 67 | for key, value in header.items(): 68 | base[key] = value 69 | return base 70 | 71 | def _validate_args(self, bodyparams): 72 | for key, value in bodyparams.items(): 73 | if isinstance(value, list): 74 | bodyparams[key] = json.dumps(value) 75 | return bodyparams 76 | 77 | def get(self, resource_endpoint, query_params={}): 78 | """ 79 | This a method for internal use only. 80 | It is used to make a GET request to the Hrflow API. 81 | It's not meant to be used directly by the user. 82 | 83 | Args: 84 | resource_endpoint: 85 | The resource endpoint. For example: "job/indexing" 86 | 87 | query_params: 88 | The query parameters to be sent to the API. It 89 | must be a dictionary. 90 | 91 | Returns 92 | Make the corresponding GET request to the Hrflow API and returns the 93 | response object. 94 | """ 95 | url = self._create_request_url(resource_endpoint) 96 | if query_params: 97 | query_params = self._validate_args(query_params) 98 | return req.get(url, headers=self.auth_header, params=query_params) 99 | else: 100 | return req.get(url, headers=self.auth_header) 101 | 102 | def post(self, resource_endpoint, data={}, json={}, files=None): 103 | """ 104 | This a method for internal use only. 105 | It is used to make a POST request to the Hrflow API. 106 | It's not meant to be used directly by the user. 107 | 108 | Args: 109 | resource_endpoint: 110 | The resource endpoint. For example: "job/indexing" 111 | 112 | data: 113 | The data payload (for multipart/formdata) to be 114 | sent to the API. It must be a dictionary. 115 | 116 | json: 117 | The json payload to be sent to the API. It must 118 | be a dictionary. 119 | 120 | files: 121 | The files payload to be sent to the API. It must 122 | be a dictionary. (ie. {"file": open("file.pdf", 123 | "rb")} 124 | 125 | Returns: 126 | Makes the corresponding POST request to the Hrflow API and returns the 127 | response object. 128 | """ 129 | url = self._create_request_url(resource_endpoint) 130 | if files: 131 | data = self._validate_args(data) 132 | return req.post(url, headers=self.auth_header, files=files, data=data) 133 | else: 134 | return req.post(url, headers=self.auth_header, data=data, json=json) 135 | 136 | def patch(self, resource_endpoint, json={}): 137 | """ 138 | This a method for internal use only. 139 | It is used to make a PATCH request to the Hrflow API. 140 | It's not meant to be used directly by the user. 141 | 142 | Args: 143 | resource_endpoint: 144 | The resource endpoint. For example: "job/indexing" 145 | 146 | json: 147 | The json payload to be sent to the API. It must 148 | be a dictionary. 149 | 150 | Returns: 151 | Makes the corresponding PATCH request to the Hrflow API and returns the 152 | response object. 153 | """ 154 | url = self._create_request_url(resource_endpoint) 155 | data = self._validate_args(json) 156 | return req.patch(url, headers=self.auth_header, json=data) 157 | 158 | def put(self, resource_endpoint, json={}): 159 | """ 160 | This a method for internal use only. 161 | It is used to make a PUT request to the Hrflow API. 162 | It's not meant to be used directly by the user. 163 | 164 | Args: 165 | resource_endpoint: 166 | The resource endpoint. For example: "job/indexing" 167 | 168 | json: 169 | The json payload to be sent to the API. It must 170 | be a dictionary. 171 | 172 | Returns: 173 | Makes the corresponding PUT request to the Hrflow API and returns the 174 | response object. 175 | """ 176 | url = self._create_request_url(resource_endpoint) 177 | return req.put(url, headers=self.auth_header, json=json) 178 | -------------------------------------------------------------------------------- /tests/utils/tools.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import io 3 | import os 4 | import typing as t 5 | from datetime import datetime, timezone 6 | 7 | import requests 8 | from dotenv import load_dotenv 9 | from pydantic import BaseModel 10 | from pytest import fail, skip 11 | 12 | from hrflow import Hrflow 13 | 14 | from .schemas import JobIndexingResponse, ProfileIndexingResponse 15 | 16 | _env_loaded = False 17 | 18 | 19 | def _var_from_env_get(varname: str) -> str: 20 | """ 21 | Gets the value of the specified variable (`varname`) from the environment. 22 | 23 | Args: 24 | varname (str): The name of the variable to retrieve. 25 | 26 | Returns: 27 | The value corresponding to `varname` in the environment if found; otherwise, 28 | the test calling this function will be skipped. 29 | """ 30 | 31 | # this allows to load the environment once 32 | global _env_loaded 33 | if not _env_loaded: 34 | load_dotenv() 35 | _env_loaded = True 36 | 37 | value = os.environ.get(varname) 38 | if value is None: 39 | skip(f"{varname} was not found in the environment") 40 | 41 | return value 42 | 43 | 44 | def _now_iso8601_get() -> str: 45 | return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S%z") 46 | 47 | 48 | def _iso8601_to_datetime(datestr: str) -> t.Optional[datetime]: 49 | try: 50 | return datetime.fromisoformat(datestr) 51 | except Exception: 52 | pass 53 | 54 | 55 | def _file_get(url: str) -> t.Optional[t.Union[io.BytesIO, io.BufferedReader]]: 56 | """ 57 | Gets the file corresponding to the specified `url`. 58 | This function avoids downloading the same file multiple times using caching based on 59 | the hash of the URL. If tests/assets/`` does not exist, it will 60 | be downloaded from `url` and stored for reuse. 61 | 62 | Args: 63 | url (str): The download URL of the file. 64 | file_name (optional[str]): The name to assign to the file. 65 | 66 | Returns: 67 | The content of the file if it exists; otherwise, returns `None`. 68 | """ 69 | url_hash = hashlib.md5(url.encode()).hexdigest() 70 | 71 | last_slash_index = url.rfind("/") + 1 72 | file_name = url[last_slash_index:] 73 | 74 | # look up for its cached version 75 | dir_path = "tests/assets" 76 | file_path = os.path.join(dir_path, url_hash) 77 | if os.path.isfile(file_path): 78 | with open(file_path, "rb") as file: 79 | file_object = io.BytesIO(file.read()) 80 | file_object.name = file_name 81 | return file_object 82 | 83 | response = requests.get(url) 84 | 85 | if response.status_code != requests.codes.ok: 86 | return 87 | 88 | file_data = response.content 89 | 90 | if not os.path.isdir(dir_path): 91 | os.mkdir(dir_path) 92 | 93 | # cache the content 94 | with open(file_path, "wb+") as file: 95 | file.write(file_data) 96 | 97 | file_object = io.BytesIO(file_data) 98 | file_object.name = file_name 99 | return io.BytesIO(file_data) 100 | 101 | 102 | def _check_same_keys_equality(source: t.Dict[str, t.Any], target: BaseModel): 103 | """ 104 | Performs a shallow equality check between the keys at the same levels in the 105 | dictionaries `source` and `target`. 106 | 107 | Args: 108 | source (dict): The dictionary from which `target` was derived, using indexing 109 | or other methods. 110 | target (BaseModel): The Pydantic response object corresponding to `source`. 111 | 112 | Returns: 113 | None 114 | """ 115 | 116 | dumped = target.dict() # easier to compare dict vs dict 117 | 118 | def _fail_message_get(key, source_value, target_value, is_complex=False): 119 | return ( 120 | f"{'complex' if is_complex else 'primitive'} comparison failed: '{key}' is" 121 | f" expected to be '{source_value}', got '{target_value}'" 122 | ) 123 | 124 | def _compare(source: t.Dict[str, t.Any], target: t.Dict[str, t.Any]): 125 | for key in source: 126 | 127 | # compare only the keys that are present in both AND at the same level 128 | if key not in target: 129 | continue 130 | 131 | source_value = source[key] 132 | target_value = target[key] 133 | source_value_t = type(source_value) 134 | target_value_t = type(target_value) 135 | 136 | # type comparison 137 | if source_value_t != target_value_t: 138 | fail( 139 | f"'{key}' expected to be of type '{source_value_t}', got" 140 | f" '{target_value_t}'" 141 | ) 142 | 143 | # list vs list comparison 144 | # lists are expected to be homogenous 145 | # if list type is dict, each item will be compared with a recursive call 146 | # otherwise, perform basic python comparison == 147 | elif isinstance(source_value, list): 148 | 149 | # both lists must be of same size 150 | assert len(source_value) == len(target_value) or fail( 151 | f"'{key}' is expected to be of length {len(source_value)}, but it" 152 | f" is {len(target_value)}" 153 | ) 154 | 155 | if len(source_value) == 0: 156 | continue 157 | 158 | if isinstance(source_value[0], dict): 159 | for ii in range(len(source_value)): 160 | _compare(source_value[ii], target_value[ii]) 161 | else: # basic python comparisong should be enough 162 | assert source_value == target_value or fail( 163 | _fail_message_get( 164 | key, source_value, target_value, is_complex=True 165 | ) 166 | ) 167 | 168 | # recursive call 169 | elif isinstance(source_value, dict): 170 | _compare(source_value, target_value) 171 | 172 | # basic python comparison for primitive types 173 | else: 174 | assert source_value == target_value or fail( 175 | _fail_message_get(key, source_value, target_value, is_complex=False) 176 | ) 177 | 178 | _compare(source, dumped) 179 | 180 | 181 | def _indexed_response_get( 182 | hf: Hrflow, holder_key: str, json: t.Dict[str, t.Any] 183 | ) -> t.Union[JobIndexingResponse, ProfileIndexingResponse]: 184 | """ 185 | Abstract function for indexing one-time jobs (or profiles). This function is 186 | primarily used to avoid dependencies on specific object keys when performing 187 | tasks such as archiving or editing. 188 | 189 | Args: 190 | hf (Hrflow): The Hrflow API class. 191 | holder_key (str): The key of the board (or source). 192 | json (dict): The JSON object of the job (or source). 193 | 194 | Returns: 195 | The response Pydantic class. 196 | """ 197 | 198 | is_job = "info" not in json 199 | 200 | model = (JobIndexingResponse if is_job else ProfileIndexingResponse).parse_obj( 201 | getattr(hf, "job" if is_job else "profile").storing.add_json(holder_key, json) 202 | ) 203 | 204 | assert ( 205 | model.code == requests.codes.created 206 | ), f"{model.code=} != {requests.codes.created=}, {model.message=}" 207 | 208 | return model 209 | -------------------------------------------------------------------------------- /hrflow/profile/parsing.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | import uuid 5 | 6 | from tqdm import tqdm 7 | 8 | from ..core import format_item_payload, get_files_from_dir 9 | from ..core.rate_limit import rate_limiter 10 | from ..core.validation import validate_key, validate_reference, validate_response 11 | 12 | DEFAULT_FILE_NAME = "resume.pdf" 13 | 14 | 15 | class ProfileParsing: 16 | """Manage parsing related profile calls.""" 17 | 18 | def __init__(self, api): 19 | """Init.""" 20 | self.client = api 21 | 22 | @rate_limiter 23 | def add_file( 24 | self, 25 | source_key, 26 | key=None, 27 | profile_file=None, 28 | profile_file_name=None, 29 | profile_content_type=None, 30 | reference=None, 31 | created_at=None, 32 | labels=[], 33 | tags=[], 34 | metadatas=[], 35 | sync_parsing=0, 36 | sync_parsing_indexing=1, 37 | webhook_parsing_sending=0, 38 | ): 39 | """ 40 | Add a profile resume to a sourced key. 41 | 42 | Args: 43 | source_key: 44 | source_key 45 | key 46 | source_key 47 | profile_file: 48 | profile binary 49 | profile_file_name: 50 | file name 51 | profile_content_type 52 | file content type 53 | reference: (default to None) 54 | reference to assign to the profile 55 | created_at: 56 | original date of the application of the 57 | profile as ISO format 58 | labels: 59 | profile's label 60 | tags: 61 | profile's tag 62 | metadatas: 63 | profile's metadatas 64 | sync_parsing 65 | 0 or 1 66 | sync_parsing_indexing 67 | 0 or 1 68 | webhook_parsing_sending 69 | 0 or 1 70 | 71 | Returns 72 | Response that contains code 201 if successful 73 | Other status codes otherwise. 74 | 75 | """ 76 | payload = { 77 | "source_key": validate_key("Source", source_key), 78 | "key": validate_key("profile", key), 79 | "profile_content_type": profile_content_type, 80 | "reference": validate_reference(reference), 81 | "created_at": created_at, 82 | "labels": json.dumps(labels), 83 | "tags": json.dumps(tags), 84 | "metadatas": json.dumps(metadatas), 85 | "sync_parsing": sync_parsing, 86 | "sync_parsing_indexing": sync_parsing_indexing, 87 | "webhook_parsing_sending": webhook_parsing_sending, 88 | } 89 | 90 | if profile_file_name is None: 91 | profile_file_name = DEFAULT_FILE_NAME 92 | if hasattr(profile_file, "name"): 93 | profile_file_name = ( 94 | os.path.basename(profile_file.name) or profile_file_name 95 | ) 96 | 97 | file_payload = {"file": (profile_file_name, profile_file)} 98 | response = self.client.post( 99 | "profile/parsing/file", data=payload, files=file_payload 100 | ) 101 | return validate_response(response) 102 | 103 | @rate_limiter 104 | def add_folder( 105 | self, 106 | source_key, 107 | dir_path, 108 | is_recurcive=False, 109 | created_at=None, 110 | sync_parsing=0, 111 | move_failure_to=None, 112 | show_progress=False, 113 | **kwargs, 114 | ): 115 | """ 116 | Parse a folder of profile resumes to a sourced key. 117 | 118 | This method will parse the files in the folder, with the authorized extensions, 119 | not the subfolders by default. 120 | If you want to parse the subfolders, set is_recurcive to True. 121 | 122 | Args: 123 | source_key: 124 | source_key 125 | dir_path: 126 | directory path 127 | is_recurcive: 128 | True or False 129 | created_at: 130 | original date of the application of the 131 | profile as ISO format 132 | sync_parsing 133 | 0 or 1 134 | move_failure_to 135 | directory path to move the failed files. 136 | If None, the failed files will not be moved. 137 | show_progress 138 | Show the progress bar 139 | **kwargs: <**kwargs> 140 | additional parameters to pass to the parsing API 141 | """ 142 | if not os.path.isdir(dir_path): 143 | raise ValueError(dir_path + " is not a directory") 144 | files_to_send = get_files_from_dir(dir_path, is_recurcive) 145 | succeed_upload = {} 146 | failed_upload = {} 147 | if show_progress: 148 | files_to_send = tqdm(files_to_send, "Parsing") 149 | for file_path in files_to_send: 150 | filename = os.path.basename(file_path) 151 | try: 152 | with open(file_path, "rb") as file: 153 | resp = self.add_file( 154 | source_key=source_key, 155 | profile_file=file, 156 | profile_file_name=filename, 157 | created_at=created_at, 158 | sync_parsing=sync_parsing, 159 | **kwargs, 160 | ) 161 | response_code = str(resp["code"]) # 200, 201, 202, 400, ... 162 | if response_code[0] != "2": 163 | failed_upload[file_path] = ValueError( 164 | "Invalid response: " + str(resp) 165 | ) 166 | if move_failure_to is not None: 167 | move_to_failed_dir(file_path, move_failure_to) 168 | else: 169 | succeed_upload[file_path] = resp 170 | except Exception as e: 171 | failed_upload[file_path] = e 172 | if move_failure_to is not None: 173 | move_to_failed_dir(file_path, move_failure_to) 174 | 175 | result = {"success": succeed_upload, "fail": failed_upload} 176 | return result 177 | 178 | @rate_limiter 179 | def get(self, source_key=None, key=None, reference=None, email=None): 180 | """ 181 | Retrieve Parsing information. 182 | 183 | Args: 184 | source_key: 185 | source_key 186 | key: 187 | key 188 | reference: 189 | profile_reference 190 | email: 191 | profile_email 192 | 193 | Returns 194 | Get information 195 | 196 | """ 197 | query_params = format_item_payload("profile", source_key, key, reference, email) 198 | response = self.client.get("profile/parsing", query_params) 199 | return validate_response(response) 200 | 201 | 202 | def move_to_failed_dir(file_path: str, move_failure_to: str): 203 | file_name = os.path.basename(file_path) 204 | unique_id = str(uuid.uuid4()) 205 | 206 | destination_path = os.path.join(move_failure_to, file_name) 207 | if os.path.exists(destination_path): 208 | destination_path = os.path.join( 209 | move_failure_to, f"same-file-name-{unique_id}_{file_name}" 210 | ) 211 | 212 | shutil.copyfile(file_path, destination_path) 213 | -------------------------------------------------------------------------------- /tests/test_job.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from uuid import uuid1 3 | 4 | import pytest 5 | from requests import codes as http_codes 6 | 7 | from hrflow import Hrflow 8 | 9 | from .utils.schemas import ( 10 | JobArchiveResponse, 11 | JobAskingResponse, 12 | JobIndexingResponse, 13 | JobsScoringResponse, 14 | JobsSearchingResponse, 15 | ) 16 | from .utils.tools import ( 17 | _check_same_keys_equality, 18 | _indexed_response_get, 19 | _now_iso8601_get, 20 | _var_from_env_get, 21 | ) 22 | 23 | 24 | @pytest.fixture(scope="module") 25 | def hrflow_client(): 26 | return Hrflow( 27 | api_secret=_var_from_env_get("HRFLOW_API_KEY"), 28 | api_user=_var_from_env_get("HRFLOW_USER_EMAIL"), 29 | ) 30 | 31 | 32 | def _job_get() -> t.Dict[str, t.Any]: 33 | return dict( 34 | reference=str(uuid1()), 35 | name="r&d engineer", 36 | location=dict(text="7 rue 4 septembre paris", lat=48.869179, lng=2.33814), 37 | sections=[ 38 | dict( 39 | name="Description", 40 | title="Description", 41 | description=( 42 | "As an AI Researcher Intern at HrFlow.ai, you'll play a vital role" 43 | " in driving the next phase of our exciting expansion. Your role" 44 | " involves developing innovative AI models and algorithms to tackle" 45 | " intricate HR challenges. Collaborating with fellow researchers" 46 | " and engineers, you'll help guide the technical direction and" 47 | " architecture of our AI solutions." 48 | ), 49 | ) 50 | ], 51 | url="https://www.linkedin.com/jobs/search/?currentJobId=3718625295", 52 | summary=( 53 | "As an AI Researcher Intern at HrFlow.ai, you'll play a vital role in" 54 | " driving the next phase of our exciting expansion. Your role involves" 55 | " developing innovative AI models and algorithms to tackle intricate HR" 56 | " challenges. Collaborating with fellow researchers and engineers, you'll" 57 | " help guide the technical direction and architecture of our AI solutions." 58 | ), 59 | created_at=_now_iso8601_get(), 60 | skills=[dict(name="Deep Learning", type="hard", value="95/100")], 61 | languages=[dict(name="French", value="Fluent")], 62 | certifications=[dict(name="ISO 27001", value="Individual")], 63 | courses=[dict(name="Statistical Learning", value="On campus")], 64 | tasks=[dict(name="Developing innovative AI models", value="Innovating")], 65 | tags=[dict(name="Curios", value="1")], 66 | metadatas=[ 67 | dict(name="Interview note", value="Today, I met an amazing candidate...") 68 | ], 69 | ranges_float=[ 70 | dict(name="salary", value_min=1234.56, value_max=6543.21, unit="euros") 71 | ], 72 | ranges_date=[ 73 | dict( 74 | name="dates", 75 | value_min="2023-06-01T23:00:00.000Z", 76 | value_max="2023-09-01T23:00:00.000Z", 77 | ) 78 | ], 79 | culture="We love AI engineering, problem-solving, and business.", 80 | responsibilities=( 81 | "Designing, implementing, and optimizing AI models and algorithms that" 82 | " solve complex HR challenges. Analyzing and evaluating the performance of" 83 | " AI models and algorithms. Collaborating with other researchers and" 84 | " engineers to improve the overall performance and accuracy of our AI" 85 | " solutions. Staying up-to-date with the latest developments in AI research" 86 | " and technology. Communicating and presenting research findings to" 87 | " internal and external stakeholders." 88 | ), 89 | requirements=( 90 | "Enrolled in an advanced degree in Computer Science, Artificial" 91 | " Intelligence, or a related field. Proficiency in developing and" 92 | " implementing AI models and algorithms. Strong programming skills in" 93 | " Python. Experience with deep learning frameworks like TensorFlow," 94 | " PyTorch, or Keras. Solid grasp of machine learning fundamentals and" 95 | " statistical analysis. Exceptional problem-solving and analytical" 96 | " abilities. Effective communication and collaboration skills." 97 | ), 98 | interviews=( 99 | "Interview with one of our lead AI Researcher to discuss your experience" 100 | " and qualifications in more detail. Interview with our Chief Executive" 101 | " Officer to discuss your fit within our organization and your career" 102 | " goals." 103 | ), 104 | benefits=( 105 | "Go fast and learn a lot. High-impact position and responsibilities without" 106 | " any day being the same. Competitive salary and variable compensation. Gym" 107 | " club & public transportation. Fun & smart colleagues. Latest hardware." 108 | ), 109 | ) 110 | 111 | 112 | @pytest.mark.job 113 | @pytest.mark.indexing 114 | def test_job_indexing_basic(hrflow_client): 115 | job = _job_get() 116 | model = JobIndexingResponse.parse_obj( 117 | hrflow_client.job.storing.add_json( 118 | board_key=_var_from_env_get("HRFLOW_BOARD_KEY"), 119 | job_json=job, 120 | ) 121 | ) 122 | assert model.code == http_codes.created 123 | assert model.data is not None 124 | _check_same_keys_equality(job, model.data) 125 | 126 | 127 | @pytest.mark.job 128 | @pytest.mark.searching 129 | def test_job_searching_basic(hrflow_client): 130 | model = JobsSearchingResponse.parse_obj( 131 | hrflow_client.job.searching.list( 132 | board_keys=[_var_from_env_get("HRFLOW_BOARD_KEY")], 133 | limit=5, # allows to bypass the bug with archived jobs 134 | ) 135 | ) 136 | assert model.code == http_codes.ok 137 | assert len(model.data.jobs) == model.meta.count 138 | 139 | 140 | @pytest.mark.job 141 | @pytest.mark.scoring 142 | def test_job_scoring_basic(hrflow_client): 143 | model = JobsScoringResponse.parse_obj( 144 | hrflow_client.job.scoring.list( 145 | algorithm_key=_var_from_env_get("HRFLOW_ALGORITHM_KEY"), 146 | board_keys=[_var_from_env_get("HRFLOW_BOARD_KEY")], 147 | profile_key=_var_from_env_get("HRFLOW_PROFILE_KEY"), 148 | source_key=_var_from_env_get("HRFLOW_SOURCE_KEY_QUICKSILVER_SYNC"), 149 | limit=5, # allows to bypass the bug with archived jobs 150 | ) 151 | ) 152 | assert ( 153 | model.code == http_codes.ok 154 | ), "Maybe the job is not already indexed for the scoring. Please, try again later." 155 | assert len(model.data.jobs) == len(model.data.predictions) 156 | 157 | 158 | @pytest.mark.job 159 | @pytest.mark.asking 160 | def test_job_asking_basic(hrflow_client): 161 | BOARD_KEY = _var_from_env_get("HRFLOW_BOARD_KEY") 162 | model = JobAskingResponse.parse_obj( 163 | hrflow_client.job.asking.get( 164 | board_key=BOARD_KEY, 165 | key=_indexed_response_get(hrflow_client, BOARD_KEY, _job_get()).data.key, 166 | questions=[ 167 | "What is the company proposing this job offer ?", 168 | ], 169 | ) 170 | ) 171 | assert model.code == http_codes.ok 172 | assert len(model.data) == 1 173 | assert "hrflow.ai" in model.data[0].lower() 174 | 175 | 176 | @pytest.mark.skip(reason="backend: multiple questions are not correctly handled yet") 177 | @pytest.mark.job 178 | @pytest.mark.asking 179 | def test_job_asking_multiple_questions(hrflow_client): 180 | BOARD_KEY = _var_from_env_get("HRFLOW_BOARD_KEY") 181 | questions = [ 182 | "What is the job title ?", 183 | "What is the company proposing this job offer ?", 184 | "What is the job location address ?", 185 | "What are the expected skills for this job ?", 186 | ] 187 | model = JobAskingResponse.parse_obj( 188 | hrflow_client.job.asking.get( 189 | board_key=BOARD_KEY, 190 | key=_indexed_response_get(hrflow_client, BOARD_KEY, _job_get()).data.key, 191 | questions=questions, 192 | ) 193 | ) 194 | assert model.code == http_codes.ok 195 | assert len(model.data) == len(questions) 196 | assert "r&d engineer" in model.data[0].lower() 197 | assert "hrflow.ai" in model.data[1].lower() 198 | assert "7 rue 4 septembre" in model.data[2].lower() 199 | assert "deep learning" in model.data[3].lower() 200 | 201 | 202 | @pytest.mark.job 203 | @pytest.mark.asking 204 | def test_job_asking_no_questions(hrflow_client): 205 | BOARD_KEY = _var_from_env_get("HRFLOW_BOARD_KEY") 206 | model = JobAskingResponse.parse_obj( 207 | hrflow_client.job.asking.get( 208 | board_key=BOARD_KEY, 209 | key=_indexed_response_get(hrflow_client, BOARD_KEY, _job_get()).data.key, 210 | questions=None, 211 | ) 212 | ) 213 | assert model.code == http_codes.bad_request 214 | 215 | 216 | @pytest.mark.job 217 | @pytest.mark.archive 218 | def test_job_archive_basic(hrflow_client): 219 | BOARD_KEY = _var_from_env_get("HRFLOW_BOARD_KEY") 220 | mock_key = _indexed_response_get(hrflow_client, BOARD_KEY, _job_get()).data.key 221 | model = JobArchiveResponse.parse_obj( 222 | hrflow_client.job.storing.archive(board_key=BOARD_KEY, key=mock_key) 223 | ) 224 | assert model.code == http_codes.ok 225 | assert model.data.key == mock_key 226 | 227 | 228 | @pytest.mark.job 229 | @pytest.mark.editing 230 | def test_job_editing_basic(hrflow_client): 231 | BOARD_KEY = _var_from_env_get("HRFLOW_BOARD_KEY") 232 | mock_job = _indexed_response_get(hrflow_client, BOARD_KEY, _job_get()).data 233 | mock_job.interviews = ( 234 | f"To access the interview call you must use the token {uuid1()}." 235 | ) 236 | model = JobIndexingResponse.parse_obj( 237 | hrflow_client.job.storing.edit( 238 | board_key=BOARD_KEY, 239 | job_json=mock_job.dict(), 240 | ) 241 | ) 242 | assert model.code == http_codes.ok 243 | assert model.data.interviews == mock_job.interviews 244 | -------------------------------------------------------------------------------- /hrflow/utils/evaluation/job.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from openpyxl.utils.cell import get_column_interval 4 | from openpyxl.worksheet.worksheet import Worksheet 5 | from pydantic import BaseModel 6 | from tqdm import tqdm 7 | 8 | TEMPLATE_URL = "https://riminder-documents-eu-2019-12-dev.s3.eu-west-1.amazonaws.com/evaluation/parsing-evaluation-template-v3-job.xlsx" # noqa: E501 9 | START_ROW_ID = 5 10 | 11 | NAME_COLUMN_ID = "A" 12 | URL_COLUMN_ID = "B" 13 | KEY_COLUMN_ID = "C" 14 | 15 | OVERVIEW_FIELD_LIST = ( 16 | "score", 17 | "name", 18 | "location", 19 | "summary", 20 | "culture", 21 | "benefits", 22 | "responsibilities", 23 | "requirements", 24 | "interviews", 25 | ) 26 | INFO_START_COLUMN_ID, INFO_END_COLUMN_ID = ("D", "L") 27 | 28 | RANGES_FLOATS_FIELD_LIST = ( 29 | "score", 30 | "count", 31 | "name", 32 | "value_min", 33 | "value_max", 34 | "unit", 35 | ) 36 | EXPERIENCE_START_COLUMN_ID, EXPERIENCE_END_COLUMN_ID = ("M", "R") 37 | 38 | RANGES_DATES_FIELD_LIST = ( 39 | "score", 40 | "count", 41 | "name", 42 | "value_min", 43 | "value_max", 44 | ) 45 | EDUCATION_START_COLUMN_ID, EDUCATION_END_COLUMN_ID = ("S", "W") 46 | 47 | OTHER_FIELD_LIST = ( 48 | "skills", 49 | "languages", 50 | "tasks", 51 | "courses", 52 | "certifications", 53 | ) 54 | OTHER_START_COLUMN_ID, OTHER_END_COLUMN_ID = ("X", "AB") 55 | 56 | 57 | class OverviewEvaluation(BaseModel): 58 | score: float 59 | name: int 60 | location: int 61 | summary: int 62 | culture: int 63 | benefits: int 64 | responsibilities: int 65 | requirements: int 66 | interviews: int 67 | 68 | @staticmethod 69 | def from_job(job: t.Dict[str, t.Any]) -> "OverviewEvaluation": 70 | name = 1 if job.get("name") else 0 71 | location = 1 if job.get("location", {}).get("text") else 0 72 | summary = 1 if job.get("summary") else 0 73 | culture = 1 if job.get("culture") else 0 74 | benefits = 1 if job.get("benefits") else 0 75 | responsibilities = 1 if job.get("responsibilities") else 0 76 | requirements = 1 if job.get("requirements") else 0 77 | interviews = 1 if job.get("interviews") else 0 78 | 79 | score = ( 80 | name 81 | + location 82 | + summary 83 | + culture 84 | + benefits 85 | + responsibilities 86 | + requirements 87 | + interviews 88 | ) 89 | score /= 8 90 | return OverviewEvaluation( 91 | score=score, 92 | name=name, 93 | location=location, 94 | summary=summary, 95 | culture=culture, 96 | benefits=benefits, 97 | responsibilities=responsibilities, 98 | requirements=requirements, 99 | interviews=interviews, 100 | ) 101 | 102 | 103 | class RangeFloatEvaluation(BaseModel): 104 | score: float 105 | count: int 106 | name: float 107 | value_min: float 108 | value_max: float 109 | unit: float 110 | 111 | @staticmethod 112 | def from_job(job: t.Dict[str, t.Any]) -> "RangeFloatEvaluation": 113 | count = len(job.get("ranges_float", [])) 114 | 115 | name = 0 116 | value_min = 0 117 | value_max = 0 118 | unit = 0 119 | for range_float in job.get("ranges_floats", []): 120 | name += 1 if range_float.get("name") else 0 121 | value_min += 1 if range_float.get("value_min") else 0 122 | value_max += 1 if range_float.get("value_max") else 0 123 | unit += 1 if range_float.get("unit") else 0 124 | 125 | if count > 0: 126 | name /= count 127 | value_min /= count 128 | value_max /= count 129 | unit /= count 130 | 131 | score = name + value_min + value_max + unit 132 | score /= 4 133 | 134 | return RangeFloatEvaluation( 135 | score=score, 136 | count=count, 137 | name=name, 138 | value_min=value_min, 139 | value_max=value_max, 140 | unit=unit, 141 | ) 142 | 143 | 144 | class RangeDateEvaluation(BaseModel): 145 | score: float 146 | count: int 147 | name: float 148 | value_min: float 149 | value_max: float 150 | 151 | @staticmethod 152 | def from_job(job: t.Dict[str, t.Any]) -> "RangeDateEvaluation": 153 | count = len(job.get("ranges_date", [])) 154 | 155 | name = 0 156 | value_min = 0 157 | value_max = 0 158 | for range_date in job.get("ranges_dates", []): 159 | name += 1 if range_date.get("name") else 0 160 | value_min += 1 if range_date.get("value_min") else 0 161 | value_max += 1 if range_date.get("value_max") else 0 162 | 163 | if count > 0: 164 | name /= count 165 | value_min /= count 166 | value_max /= count 167 | 168 | score = name + value_min + value_max 169 | score /= 3 170 | 171 | return RangeDateEvaluation( 172 | score=score, 173 | count=count, 174 | name=name, 175 | value_min=value_min, 176 | value_max=value_max, 177 | ) 178 | 179 | 180 | class OtherEvaluation(BaseModel): 181 | skills: int 182 | languages: int 183 | tasks: int 184 | courses: int 185 | certifications: int 186 | 187 | @staticmethod 188 | def from_job(job: t.Dict[str, t.Any]) -> "OtherEvaluation": 189 | skills = len(job.get("skills", [])) 190 | languages = len(job.get("languages", [])) 191 | tasks = len(job.get("tasks", [])) 192 | courses = len(job.get("courses", [])) 193 | certifications = len(job.get("certifications", [])) 194 | 195 | return OtherEvaluation( 196 | skills=skills, 197 | languages=languages, 198 | tasks=tasks, 199 | courses=courses, 200 | certifications=certifications, 201 | ) 202 | 203 | 204 | class JobEvaluation(BaseModel): 205 | overview: OverviewEvaluation 206 | range_float: RangeFloatEvaluation 207 | range_date: RangeDateEvaluation 208 | other: OtherEvaluation 209 | 210 | name: str 211 | url: str 212 | key: str 213 | 214 | @staticmethod 215 | def from_job(job: t.Dict[str, t.Any]) -> "JobEvaluation": 216 | return JobEvaluation( 217 | overview=OverviewEvaluation.from_job(job), 218 | range_float=RangeFloatEvaluation.from_job(job), 219 | range_date=RangeDateEvaluation.from_job(job), 220 | other=OtherEvaluation.from_job(job), 221 | name=job.get("name") or "", 222 | url=job.get("url") or "", 223 | key=job.get("key") or "", 224 | ) 225 | 226 | 227 | def parsing_evaluator( 228 | job_list: t.List[t.Dict[str, t.Any]], show_progress: bool = False 229 | ) -> t.List[JobEvaluation]: 230 | """ 231 | Evaluate a list of jobs 232 | 233 | Args: 234 | job_list: > 235 | List of jobs 236 | show_progress: 237 | Show the progress bar 238 | 239 | Returns: 240 | : 241 | List of job evaluations 242 | """ 243 | if show_progress: 244 | job_list = tqdm(job_list, desc="Evaluating jobs") 245 | return [JobEvaluation.from_job(job) for job in job_list] 246 | 247 | 248 | def fill_metadata( 249 | work_sheet: Worksheet, 250 | job_eval_list: t.List[JobEvaluation], 251 | show_progress: bool = False, 252 | ) -> None: 253 | """ 254 | Fill the metadata of the jobs in the worksheet 255 | 256 | Args: 257 | work_sheet: 258 | The worksheet to fill 259 | job_eval_list: 260 | The list of job evaluations 261 | show_progress: 262 | Show the progress bar 263 | """ 264 | if show_progress: 265 | job_eval_list = tqdm(job_eval_list, desc="Filling meta-data") 266 | for row_id, job_eval in enumerate(job_eval_list, START_ROW_ID): 267 | work_sheet[f"{NAME_COLUMN_ID}{row_id}"].value = job_eval.name 268 | work_sheet[f"{KEY_COLUMN_ID}{row_id}"].value = job_eval.key 269 | if job_eval.url: 270 | work_sheet[f"{URL_COLUMN_ID}{row_id}"].hyperlink = job_eval.url 271 | 272 | 273 | def fill_overview( 274 | work_sheet: Worksheet, 275 | job_eval_list: t.List[JobEvaluation], 276 | show_progress: bool = False, 277 | ) -> None: 278 | """ 279 | Fill the overview scores of the jobs in the worksheet 280 | 281 | Args: 282 | work_sheet: 283 | The worksheet to fill 284 | job_eval_list: 285 | The list of job evaluations 286 | show_progress: 287 | Show the progress bar 288 | """ 289 | colum_id_list = get_column_interval(INFO_START_COLUMN_ID, INFO_END_COLUMN_ID) 290 | if show_progress: 291 | job_eval_list = tqdm(job_eval_list, desc="Filling overview scores") 292 | for row_id, job_eval in enumerate(job_eval_list, START_ROW_ID): 293 | for column_id, field in zip(colum_id_list, OVERVIEW_FIELD_LIST): 294 | work_sheet[f"{column_id}{row_id}"].value = getattr(job_eval.overview, field) 295 | 296 | 297 | def fill_range_float( 298 | work_sheet: Worksheet, 299 | job_eval_list: t.List[JobEvaluation], 300 | show_progress: bool = False, 301 | ) -> None: 302 | """ 303 | Fill the range float scores of the jobs in the worksheet 304 | 305 | Args: 306 | work_sheet: 307 | The worksheet to fill 308 | job_eval_list: 309 | The list of job evaluations 310 | show_progress: 311 | Show the progress bar 312 | """ 313 | colum_id_list = get_column_interval( 314 | EXPERIENCE_START_COLUMN_ID, EXPERIENCE_END_COLUMN_ID 315 | ) 316 | if show_progress: 317 | job_eval_list = tqdm(job_eval_list, desc="Filling range float scores") 318 | for row_id, job_eval in enumerate(job_eval_list, START_ROW_ID): 319 | for column_id, field in zip(colum_id_list, RANGES_FLOATS_FIELD_LIST): 320 | work_sheet[f"{column_id}{row_id}"].value = getattr( 321 | job_eval.range_float, field 322 | ) 323 | 324 | 325 | def fill_range_date( 326 | work_sheet: Worksheet, 327 | job_eval_list: t.List[JobEvaluation], 328 | show_progress: bool = False, 329 | ) -> None: 330 | """ 331 | Fill the range date scores of the jobs in the worksheet 332 | 333 | Args: 334 | work_sheet: 335 | The worksheet to fill 336 | job_eval_list: 337 | The list of job evaluations 338 | show_progress: 339 | Show the progress bar 340 | """ 341 | colum_id_list = get_column_interval( 342 | EDUCATION_START_COLUMN_ID, EDUCATION_END_COLUMN_ID 343 | ) 344 | if show_progress: 345 | job_eval_list = tqdm(job_eval_list, desc="Filling range date scores") 346 | for row_id, job_eval in enumerate(job_eval_list, START_ROW_ID): 347 | for column_id, field in zip(colum_id_list, RANGES_DATES_FIELD_LIST): 348 | work_sheet[f"{column_id}{row_id}"].value = getattr( 349 | job_eval.range_date, field 350 | ) 351 | 352 | 353 | def fill_other( 354 | work_sheet: Worksheet, 355 | job_eval_list: t.List[JobEvaluation], 356 | show_progress: bool = False, 357 | ) -> None: 358 | """ 359 | Fill the other scores of the jobs in the worksheet 360 | 361 | Args: 362 | work_sheet: 363 | The worksheet to fill 364 | job_eval_list: 365 | The list of job evaluations 366 | show_progress: 367 | Show the progress bar 368 | """ 369 | colum_id_list = get_column_interval(OTHER_START_COLUMN_ID, OTHER_END_COLUMN_ID) 370 | if show_progress: 371 | job_eval_list = tqdm(job_eval_list, desc="Filling other scores") 372 | for row_id, job_eval in enumerate(job_eval_list, START_ROW_ID): 373 | for column_id, field in zip(colum_id_list, OTHER_FIELD_LIST): 374 | work_sheet[f"{column_id}{row_id}"].value = getattr(job_eval.other, field) 375 | 376 | 377 | def fill_work_sheet( 378 | work_sheet: Worksheet, 379 | job_eval_list: t.List[JobEvaluation], 380 | show_progress: bool = False, 381 | ) -> None: 382 | """ 383 | Fill the work sheet with the job evaluations 384 | 385 | Args: 386 | work_sheet: 387 | The worksheet to fill 388 | job_eval_list: 389 | The list of job evaluations 390 | show_progress: 391 | Show the progress bar 392 | """ 393 | fill_metadata(work_sheet, job_eval_list, show_progress) 394 | fill_overview(work_sheet, job_eval_list, show_progress) 395 | fill_range_float(work_sheet, job_eval_list, show_progress) 396 | fill_range_date(work_sheet, job_eval_list, show_progress) 397 | fill_other(work_sheet, job_eval_list, show_progress) 398 | -------------------------------------------------------------------------------- /tests/test_text.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import pytest 4 | import requests 5 | 6 | from hrflow import Hrflow 7 | 8 | from .utils.enums import TAGGING_ALGORITHM 9 | from .utils.schemas import ( 10 | TextEmbeddingResponse, 11 | TextImagingResponse, 12 | TextLinkingResponse, 13 | TextOCRResponse, 14 | TextParsingResponse, 15 | TextTaggingDataItem, 16 | TextTaggingReponse, 17 | ) 18 | from .utils.tools import _file_get, _var_from_env_get 19 | 20 | MARY_PDF_URL = """https://riminder-documents-eu-2019-12.s3-eu-west-1.amazonaws.com/\ 21 | teams/fc9d40fd60e679119130ea74ae1d34a3e22174f2/sources/07065e555609a231752a586afd6\ 22 | 495c951bbae6b/profiles/52e3c23a5f21190c59f53c41b5630ecb5d414f94/parsing/resume.pdf""" 23 | TAGGING_TEXTS = [ 24 | ( 25 | "Data Insights Corp. is seeking a Senior Data Scientist for a" 26 | " contract-to-direct position. You will be responsible for designing and" 27 | " implementing advanced machine learning algorithms and playing a key role in" 28 | " shaping our data science initiatives. The CDI arrangement offers a pathway to" 29 | " a full-time role" 30 | ), 31 | ( 32 | "DataTech Solutions is hiring a Data Scientist for a fixed-term contract of 12" 33 | " months. You will work on various data analysis and modeling projects and" 34 | " assisting in short-term projects; with the possibility of extension or" 35 | " permanent roles" 36 | ), 37 | ] 38 | 39 | 40 | @pytest.fixture(scope="module") 41 | def hrflow_client(): 42 | return Hrflow( 43 | api_secret=_var_from_env_get("HRFLOW_API_KEY"), 44 | api_user=_var_from_env_get("HRFLOW_USER_EMAIL"), 45 | ) 46 | 47 | 48 | @pytest.mark.text 49 | @pytest.mark.embedding 50 | def test_embedding_basic(hrflow_client): 51 | text = "I love using embeddings in order do transfer learning with my AI algorithms" 52 | model = TextEmbeddingResponse.parse_obj( 53 | hrflow_client.text.embedding.post(text=text) 54 | ) 55 | assert model.code == requests.codes.ok 56 | assert len(model.data) > 0 57 | 58 | 59 | @pytest.mark.text 60 | @pytest.mark.embedding 61 | def test_embedding_no_text(hrflow_client): 62 | model = TextEmbeddingResponse.parse_obj( 63 | hrflow_client.text.embedding.post(text=None) 64 | ) 65 | assert model.code == requests.codes.bad_request 66 | assert "null" in model.message.lower() 67 | 68 | 69 | def _image_sizes_get(content: bytes) -> int: 70 | w = int.from_bytes(content[16:20], byteorder="big") 71 | h = int.from_bytes(content[20:24], byteorder="big") 72 | return w, h 73 | 74 | 75 | def _content_is_png(content: bytes) -> bool: 76 | return content.startswith(b"\x89PNG\r\n\x1a\n") 77 | 78 | 79 | def _imaging_test_valid_size(hrflow_client, width: t.Literal[256, 512]): 80 | model = TextImagingResponse.parse_obj( 81 | hrflow_client.text.imaging.post(text="plumber", width=width) 82 | ) 83 | assert model.code == requests.codes.ok 84 | response = requests.get(model.data.image_url) 85 | assert response.status_code == requests.codes.ok 86 | assert _content_is_png(response.content) 87 | assert _image_sizes_get(response.content) == (width, width) 88 | 89 | 90 | @pytest.mark.text 91 | @pytest.mark.imaging 92 | def test_imaging_basic_256(hrflow_client): 93 | _imaging_test_valid_size(hrflow_client, 256) 94 | 95 | 96 | @pytest.mark.text 97 | @pytest.mark.imaging 98 | def test_imaging_basic_512(hrflow_client): 99 | _imaging_test_valid_size(hrflow_client, 512) 100 | 101 | 102 | @pytest.mark.text 103 | @pytest.mark.imaging 104 | def test_imaging_unsupported_size(hrflow_client): 105 | model = TextImagingResponse.parse_obj( 106 | hrflow_client.text.imaging.post(text="mechanic", width=111) 107 | ) 108 | assert model.code == requests.codes.bad_request 109 | assert "111" in model.message 110 | 111 | 112 | @pytest.mark.text 113 | @pytest.mark.imaging 114 | def test_imaging_no_text(hrflow_client): 115 | model = TextImagingResponse.parse_obj( 116 | hrflow_client.text.imaging.post(text=None, width=256) 117 | ) 118 | assert model.code == requests.codes.bad_request 119 | assert "null" in model.message.lower() 120 | 121 | 122 | @pytest.mark.text 123 | @pytest.mark.linking 124 | def test_linking_basic(hrflow_client): 125 | top_n = 7 126 | model = TextLinkingResponse.parse_obj( 127 | hrflow_client.text.linking.post(word="ai", top_n=top_n) 128 | ) 129 | assert model.code == requests.codes.ok 130 | assert len(model.data) == top_n 131 | 132 | 133 | @pytest.mark.text 134 | @pytest.mark.linking 135 | def test_linking_no_text(hrflow_client): 136 | model = TextLinkingResponse.parse_obj( 137 | hrflow_client.text.linking.post(word=None, top_n=1) 138 | ) 139 | assert model.code == requests.codes.bad_request 140 | assert "null" in model.message.lower() 141 | 142 | 143 | @pytest.mark.text 144 | @pytest.mark.linking 145 | def test_linking_zero(hrflow_client): 146 | model = TextLinkingResponse.parse_obj( 147 | hrflow_client.text.linking.post(word="ai", top_n=0) 148 | ) 149 | assert model.code == requests.codes.ok 150 | assert len(model.data) == 0 151 | 152 | 153 | @pytest.mark.text 154 | @pytest.mark.linking 155 | @pytest.mark.skip(reason="backend: negative top_n not correctly handled yet") 156 | def test_linking_negative_amount(hrflow_client): 157 | model = TextLinkingResponse.parse_obj( 158 | hrflow_client.text.linking.post(word="ai", top_n=-42) 159 | ) 160 | assert model.code == requests.codes.bad_request 161 | 162 | 163 | @pytest.mark.text 164 | @pytest.mark.tagging 165 | def test_tagger_rome_family_with_text_param(hrflow_client): 166 | model = TextTaggingReponse.parse_obj( 167 | hrflow_client.text.tagging.post( 168 | algorithm_key=TAGGING_ALGORITHM.TAGGER_ROME_FAMILY, 169 | text=TAGGING_TEXTS[0], 170 | top_n=2, 171 | ) 172 | ) 173 | assert model.code == requests.codes.ok 174 | assert isinstance(model.data, TextTaggingDataItem) 175 | 176 | 177 | @pytest.mark.text 178 | @pytest.mark.tagging 179 | def test_tagger_rome_family_with_texts_param(hrflow_client): 180 | model = TextTaggingReponse.parse_obj( 181 | hrflow_client.text.tagging.post( 182 | algorithm_key=TAGGING_ALGORITHM.TAGGER_ROME_FAMILY, 183 | texts=TAGGING_TEXTS, 184 | top_n=2, 185 | ) 186 | ) 187 | assert model.code == requests.codes.ok 188 | assert isinstance(model.data, list) 189 | assert len(model.data) == len(TAGGING_TEXTS) 190 | 191 | 192 | @pytest.mark.text 193 | @pytest.mark.tagging 194 | def test_tagger_rome_family_with_text_and_texts_param(hrflow_client): 195 | try: 196 | TextTaggingReponse.parse_obj( 197 | hrflow_client.text.tagging.post( 198 | algorithm_key=TAGGING_ALGORITHM.TAGGER_ROME_FAMILY, 199 | text=TAGGING_TEXTS[0], 200 | texts=TAGGING_TEXTS, 201 | top_n=2, 202 | ) 203 | ) 204 | pytest.fail("Should have raised a ValueError") 205 | except ValueError: 206 | pass 207 | 208 | 209 | @pytest.mark.text 210 | @pytest.mark.tagging 211 | def test_tagger_rome_family_without_text_or_texts_param(hrflow_client): 212 | try: 213 | TextTaggingReponse.parse_obj( 214 | hrflow_client.text.tagging.post( 215 | algorithm_key=TAGGING_ALGORITHM.TAGGER_ROME_FAMILY, 216 | top_n=2, 217 | ) 218 | ) 219 | pytest.fail("Should have raised a ValueError") 220 | except ValueError: 221 | pass 222 | 223 | 224 | def _tagging_test( 225 | hrflow_client: Hrflow, 226 | algorithm_key: TAGGING_ALGORITHM, 227 | texts: t.List[str], 228 | context: t.Optional[str] = None, 229 | labels: t.Optional[t.List[str]] = None, 230 | top_n: t.Optional[int] = 1, 231 | ) -> TextTaggingReponse: 232 | model = TextTaggingReponse.parse_obj( 233 | hrflow_client.text.tagging.post( 234 | algorithm_key=algorithm_key, 235 | texts=texts, 236 | context=context, 237 | labels=labels, 238 | top_n=top_n, 239 | ) 240 | ) 241 | assert model.code == requests.codes.ok 242 | assert len(model.data) == len(texts) 243 | if algorithm_key == TAGGING_ALGORITHM.TAGGER_HRFLOW_LABELS: 244 | assert all( 245 | all( 246 | tag in labels or pytest.fail(f"{tag} not in {labels}") 247 | for tag in item.tags 248 | ) 249 | and ( 250 | all( 251 | id.isnumeric() or pytest.fail(f"{id} is not numerical") 252 | for id in item.ids 253 | ) 254 | ) 255 | for item in model.data 256 | ) 257 | return model 258 | 259 | 260 | @pytest.mark.text 261 | @pytest.mark.tagging 262 | def test_tagger_rome_family_basic(hrflow_client): 263 | _tagging_test( 264 | hrflow_client=hrflow_client, 265 | algorithm_key=TAGGING_ALGORITHM.TAGGER_ROME_FAMILY, 266 | texts=TAGGING_TEXTS, 267 | top_n=2, 268 | ) 269 | 270 | 271 | @pytest.mark.text 272 | @pytest.mark.tagging 273 | def test_tagger_rome_subfamily_basic(hrflow_client): 274 | _tagging_test( 275 | hrflow_client=hrflow_client, 276 | algorithm_key=TAGGING_ALGORITHM.TAGGER_ROME_SUBFAMILY, 277 | texts=TAGGING_TEXTS, 278 | top_n=3, 279 | ) 280 | 281 | 282 | @pytest.mark.text 283 | @pytest.mark.tagging 284 | def test_tagger_rome_category_basic(hrflow_client): 285 | _tagging_test( 286 | hrflow_client=hrflow_client, 287 | algorithm_key=TAGGING_ALGORITHM.TAGGER_ROME_CATEGORY, 288 | texts=TAGGING_TEXTS, 289 | top_n=4, 290 | ) 291 | 292 | 293 | @pytest.mark.text 294 | @pytest.mark.tagging 295 | def test_tagger_rome_jobtitle_basic(hrflow_client): 296 | _tagging_test( 297 | hrflow_client=hrflow_client, 298 | algorithm_key=TAGGING_ALGORITHM.TAGGER_ROME_JOBTITLE, 299 | texts=TAGGING_TEXTS, 300 | top_n=5, 301 | ) 302 | 303 | 304 | @pytest.mark.text 305 | @pytest.mark.tagging 306 | def test_tagger_hrflow_skills_basic(hrflow_client): 307 | _tagging_test( 308 | hrflow_client=hrflow_client, 309 | algorithm_key=TAGGING_ALGORITHM.TAGGER_HRFLOW_SKILLS, 310 | texts=TAGGING_TEXTS, 311 | top_n=6, 312 | ) 313 | 314 | 315 | @pytest.mark.text 316 | @pytest.mark.tagging 317 | def test_tagger_hrflow_labels_basic(hrflow_client): 318 | model = _tagging_test( 319 | hrflow_client=hrflow_client, 320 | algorithm_key=TAGGING_ALGORITHM.TAGGER_HRFLOW_LABELS, 321 | texts=TAGGING_TEXTS, 322 | context=( 323 | "The CDI is a Contrat à Durée Indeterminée - essentially an open-ended or" 324 | " permanent employment contract. The CDD is a Contrat à Durée Determinée -" 325 | " a fixed-term or temporary employment contract. These are the two most" 326 | " common types but by no means the only form of French employment contract." 327 | " The contracts have to be drawn up by the employer, who must ensure that" 328 | " it's legally the correct type for the circumstances." 329 | ), 330 | labels=["CDI", "CDD"], 331 | ) 332 | assert model.data[0].tags[0] == "CDI" 333 | assert model.data[1].tags[0] == "CDD" 334 | 335 | 336 | @pytest.mark.text 337 | @pytest.mark.tagging 338 | def test_tagger_hrflow_labels_no_context(hrflow_client): 339 | model = _tagging_test( 340 | hrflow_client=hrflow_client, 341 | algorithm_key=TAGGING_ALGORITHM.TAGGER_HRFLOW_LABELS, 342 | texts=[ 343 | ( 344 | "In the quantum gardens of knowledge, she cultivates algorithms," 345 | " weaving threads of brilliance through the binary blooms, a sorceress" 346 | " of AI enchantment." 347 | ), 348 | ( 349 | "In the neural realms of innovation, he navigates the data currents," 350 | " sculpting insights from the digital ether, a virtuoso of AI" 351 | " exploration." 352 | ), 353 | ], 354 | labels=["male", "female"], 355 | ) 356 | assert model.data[0].tags[0] == "female" 357 | assert model.data[1].tags[0] == "male" 358 | 359 | 360 | @pytest.mark.text 361 | @pytest.mark.ocr 362 | def test_ocr_basic(hrflow_client): 363 | file = _file_get(MARY_PDF_URL) 364 | assert file is not None 365 | model = TextOCRResponse.parse_obj(hrflow_client.text.ocr.post(file=file)) 366 | assert model.code == requests.codes.ok 367 | assert "ocr" in model.message.lower() 368 | 369 | 370 | @pytest.mark.text 371 | @pytest.mark.parsing 372 | def test_parsing_basic_with_texts_param(hrflow_client): 373 | texts = ["John Doe can be contacted on john.doe@hrflow.ai"] 374 | model = TextParsingResponse.parse_obj(hrflow_client.text.parsing.post(texts=texts)) 375 | assert model.code == requests.codes.ok 376 | assert len(model.data) == len(texts) 377 | 378 | 379 | @pytest.mark.text 380 | @pytest.mark.parsing 381 | def test_parsing_basic_with_text_param(hrflow_client): 382 | text = "John Doe can be contacted on john.doe@hrflow.ai" 383 | model = TextParsingResponse.parse_obj(hrflow_client.text.parsing.post(text=text)) 384 | assert model.code == requests.codes.ok 385 | 386 | 387 | @pytest.mark.text 388 | @pytest.mark.parsing 389 | def test_parsing_basic_with_no_text_or_texts_param(hrflow_client): 390 | with pytest.raises(ValueError): 391 | TextParsingResponse.parse_obj(hrflow_client.text.parsing.post()) 392 | 393 | 394 | @pytest.mark.text 395 | @pytest.mark.parsing 396 | def test_parsing_basic_with_text_and_texts_param(hrflow_client): 397 | text = "John Doe can be contacted on john.doe@hrflow.ai" 398 | with pytest.raises(ValueError): 399 | TextParsingResponse.parse_obj( 400 | hrflow_client.text.parsing.post(text=text, texts=[text]) 401 | ) 402 | -------------------------------------------------------------------------------- /hrflow/job/storing.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from ..core import format_item_payload 4 | from ..core.rate_limit import rate_limiter 5 | from ..core.validation import ( 6 | ORDER_BY_VALUES, 7 | SORT_BY_VALUES, 8 | validate_boolean, 9 | validate_key, 10 | validate_limit, 11 | validate_page, 12 | validate_provider_keys, 13 | validate_reference, 14 | validate_response, 15 | validate_value, 16 | ) 17 | 18 | 19 | class JobStoring: 20 | """Manage Storing related job calls.""" 21 | 22 | def __init__(self, api): 23 | """_summary_ 24 | 25 | Parameters 26 | ---------- 27 | api : _type_ 28 | _description_ 29 | """ 30 | self.client = api 31 | 32 | @rate_limiter 33 | def add_json(self, board_key, job_json): 34 | """This endpoint allows you to Index a Job object. 35 | Note: If your Job is an unstructured text, make sure to parse it first before 36 | indexing it. 37 | See how in 🧠 Parse a raw Text at: https://developers.hrflow.ai/ . 38 | Parameters 39 | ---------- 40 | board_key : string [required] 41 | Identification key of the Board attached to the Job. 42 | job_json : dict [required] 43 | A dictionary representing the HrFlow.ai Job object. The dictionary should 44 | have the following fields: 45 | 46 | - key (str): Identification key of the Job. 47 | - reference (str): Custom identifier of the Job. 48 | - name (str) [required]: Job title. 49 | - location (dict): Location information for the job. 50 | - text (str): Location text. 51 | - lat (float): Latitude coordinate. 52 | - lng (float): Longitude coordinate. 53 | - sections (list[dict]): List of sections in the job. 54 | Each section is represented by a dictionary with the following fields: 55 | - name (str): Section name. 56 | - title (str): Section title. 57 | - description (str): Section description. 58 | - url (str): Job post original URL. 59 | - summary (str): Brief summary of the Job. 60 | - created_at (str): Creation date of the Job in ISO 8601 format 61 | (YYYY-MM-DDTHH:MM:SSZ). 62 | - skills (list[dict]): List of skills required for the Job. 63 | Each skill is represented by a dictionary with the following fields: 64 | - name (str): Skill name. 65 | - type (str): Skill type: `hard` or `soft`. 66 | - value (any): Skill value. The value attached to the Skill. 67 | Example: 90/100 68 | - languages (list[dict]): List of languages required for the Job. 69 | Each language is represented by a dictionary with the following fields: 70 | - name (str): Language name. 71 | - value (any): Language value. The value attached to the Language. 72 | Example: fluent. 73 | - cetifications (list[dict]): List of certifications required for the Job. 74 | Each certification is represented by a dictionary with the following 75 | fields: 76 | - name (str): Certification name. 77 | - value (any): Certification value. The value attached to the 78 | Certification. Example: 4.5/5. 79 | - courses (list[dict]): List of courses required for the Job. 80 | Each course is represented by a dictionary with the following fields: 81 | - name (str): Course name. 82 | - value (any): Course value. The value attached to the Course. 83 | - tasks (list[dict]): List of tasks required for the Job. 84 | Each task is represented by a dictionary with the following fields: 85 | - name (str): Task name. 86 | - value (any): Task value. The value attached to the Task. 87 | - tags (list[dict]): List of tags added to the Job. Tags are a way we can 88 | extend the Job object with custom information. 89 | Each tag is represented by a dictionary with the following fields: 90 | - name (str): The name of the Tag. Example: `is_active`. 91 | - value (any): The value of the Tag. Example: `True`. 92 | - metadata (list[dict]): Custom metadata added to the Job. 93 | Each metadata is represented by a dictionary with the following fields: 94 | - name (str): The name of the metadata. Example: interview-note 95 | - value (any): The value of the metadata. Example: `The candidate was 96 | very good ...`. 97 | - ranges_float (list[dict]): List of float ranges added to the Job. 98 | Each range is represented by a dictionary with the following fields: 99 | - name (str): The name of the range. Example: salary. 100 | - value_min (float): The minimum value of the range. Example: 50000. 101 | - value_max (float): The maximum value of the range. Example: 60000. 102 | - unit (str): The unit of the range. Example: EUR. 103 | - ranges_date (list[dict]): List of date ranges added to the Job. 104 | Each range is represented by a dictionary with the following fields: 105 | - name (str): The name of the range. Example: availability. 106 | - value_min (str): The minimum value of the range in ISO 8601 format 107 | (YYYY-MM-DDTHH:MM:SSZ). Example: 2020-01-01. 108 | - value_max (str): The maximum value of the range in ISO 8601 format 109 | (YYYY-MM-DDTHH:MM:SSZ). Example: 2020-03-01. 110 | - culture (str): The company culture description in the Job. 111 | - benefits (str): The job opening benefits description in the Job. 112 | - responsibilities (str): The job opening responsibilities description in 113 | the Job. 114 | - requirements (str): The job opening requirements description in the Job. 115 | - interviews (str): The job opening interviews. 116 | Returns 117 | ------- 118 | dict 119 | Server response. 120 | """ 121 | job_json["board_key"] = validate_key("Board", board_key) 122 | response = self.client.post("job/indexing", json=job_json) 123 | return validate_response(response) 124 | 125 | @rate_limiter 126 | def edit(self, board_key, job_json, key=None): 127 | """ 128 | Edit a job already stored in the given source. 129 | This method uses the endpoint : [PUT] https://api.hrflow.ai/v1/job/indexing 130 | It requires : 131 | - source_key : The key of the source where the job is stored 132 | - job_json : The job data to update 133 | The job object must meet the criteria of the HrFlow.ai 134 | job Object 135 | Otherwise the Put request will return an error. 136 | A key or a reference must be provided in the job object 137 | `job_json`, to identify the job to update. 138 | The method will update the object already stored by the fields provided in 139 | the job_json. 140 | """ 141 | 142 | if job_json is None: 143 | job_json = {} 144 | 145 | job_json["board_key"] = validate_key("Board", board_key) 146 | # The argument key is kept for backward compatibility with previous versions 147 | # of the SDK. It should be removed in the future after a Major release. 148 | if key: 149 | job_json["key"] = validate_key("Job", key) 150 | 151 | response = self.client.put("job/indexing", json=job_json) 152 | return validate_response(response) 153 | 154 | @rate_limiter 155 | def get(self, board_key, key=None, reference=None): 156 | """ 157 | Retrieve the parsing information. 158 | 159 | Args: 160 | board_key: 161 | board id 162 | key: 163 | job id 164 | reference: 165 | job_reference 166 | 167 | Returns 168 | parsing information 169 | 170 | """ 171 | query_params = format_item_payload("job", board_key, key, reference) 172 | response = self.client.get("job/indexing", query_params) 173 | return validate_response(response) 174 | 175 | @rate_limiter 176 | def archive(self, board_key, key=None, reference=None): 177 | """ 178 | This method allows to archive (is_archive=1) or unarchive (is_archive=0) a job 179 | in HrFlow.ai. 180 | The job is identified by either its key or its reference, 181 | at least one of the two values must be provided. 182 | 183 | Args: 184 | board_key: 185 | board identifier 186 | key: 187 | job identifier (key) 188 | reference: 189 | job identifier (reference) 190 | 191 | Returns 192 | Archive/unarchive job response 193 | 194 | """ 195 | payload = format_item_payload("job", board_key, key, reference) 196 | payload["is_archive"] = 1 197 | response = self.client.patch("job/indexing/archive", json=payload) 198 | return validate_response(response) 199 | 200 | @rate_limiter 201 | def list( 202 | self, 203 | board_keys, 204 | name=None, 205 | key=None, 206 | reference=None, 207 | location_lat=None, 208 | location_lon=None, 209 | location_dist=None, 210 | return_job=False, 211 | page=1, 212 | limit=30, 213 | order_by="desc", 214 | sort_by="created_at", 215 | created_at_min=None, 216 | created_at_max=None, 217 | ): 218 | """ 219 | This method allows you to retrieve the list of jobs stored in a Board. 220 | 221 | Args: 222 | board_keys: 223 | The list of the keys of the Boards containing the 224 | targeted Jobs. Example : ["xxx", "yyy", "zzz"] 225 | name: 226 | The name of the targeted Jobs. 227 | key: 228 | The key (job's unique identifier) of the targeted Jobs. 229 | reference: 230 | The reference of the targeted Jobs. 231 | location_lat: 232 | The latitude of the targeted Jobs. 233 | location_lon: 234 | The longitude of the targeted Jobs. 235 | location_dist: 236 | The distance of the targeted Jobs. (Set a radius 237 | around the Jobs'' location address (in Km).) 238 | return_job: 239 | If set to true, the full JSON of each job in the 240 | array response will be returned, otherwise only the 241 | dates, the reference and the keys. 242 | page: 243 | The page number of the targeted Jobs. 244 | limit: 245 | The number of Jobs to return per page. 246 | order_by: 247 | The order of the Jobs to return. Possible values are 248 | "asc" and "desc". 249 | sort_by: 250 | The field on which the Jobs will be sorted. Possible 251 | values are "created_at" or "updated_at". 252 | created_at_min: 253 | The minimum date of creation of the targeted Jobs. 254 | Format : "YYYY-MM-DD". 255 | created_at_max: 256 | The maximum date of creation of the targeted Jobs. 257 | Format : "YYYY-MM-DD". 258 | Returns: 259 | Applies the params to filter on Jobs in the targeted Boards and returns 260 | the response from the endpoint. 261 | Response examples : 262 | - Success response : 263 | { 264 | "code": 200, # response code 265 | "message": "List of jobs", # response message 266 | "meta" : {'page': 1, # current page 267 | 'maxPage': 5, # max page in the paginated response 268 | 'count': 2, # number of jobs in the current page 269 | 'total': 10}, # total number of jobs retrieved 270 | "data": [ # list of jobs objects 271 | { 272 | "key": "xxx", 273 | "reference": "xxx", 274 | ... 275 | }, 276 | ... 277 | ] 278 | } 279 | - Error response : 280 | { 281 | "code": 400, 282 | "message": "Invalid parameters. Unable to find object: board" 283 | } 284 | """ 285 | 286 | params = { 287 | "board_keys": json.dumps(validate_provider_keys(board_keys)), 288 | "name": validate_key("Job", name), 289 | "key": validate_key("Job", key), 290 | "reference": validate_reference(reference), 291 | "location_lat": location_lat, # TODO : validate_location_lat(location_lat), 292 | "location_lon": location_lon, # TODO : validate_location_lon(location_lon), 293 | "location_dist": location_dist, 294 | "return_job": validate_boolean("return_job", return_job), 295 | "page": validate_page(page), 296 | "limit": validate_limit(limit), 297 | "order_by": validate_value(order_by, ORDER_BY_VALUES, "order by"), 298 | "sort_by": validate_value(sort_by, SORT_BY_VALUES, "sort by"), 299 | "created_at_min": created_at_min, # TODO validate dates format 300 | "created_at_max": created_at_max, # TODO validate dates format 301 | } 302 | 303 | response = self.client.get("storing/jobs", params) 304 | return validate_response(response) 305 | -------------------------------------------------------------------------------- /hrflow/schemas.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | 6 | class LocationFields(BaseModel): 7 | category: t.Optional[str] 8 | city: t.Optional[str] 9 | city_district: t.Optional[str] 10 | country: t.Optional[str] 11 | country_region: t.Optional[str] 12 | entrance: t.Optional[str] 13 | house: t.Optional[str] 14 | house_number: t.Optional[str] 15 | island: t.Optional[str] 16 | level: t.Optional[str] 17 | near: t.Optional[str] 18 | po_box: t.Optional[str] 19 | postcode: t.Optional[str] 20 | road: t.Optional[str] 21 | staircase: t.Optional[str] 22 | state: t.Optional[str] 23 | state_district: t.Optional[str] 24 | suburb: t.Optional[str] 25 | text: t.Optional[str] 26 | unit: t.Optional[str] 27 | world_region: t.Optional[str] 28 | 29 | 30 | class Location(BaseModel): 31 | text: t.Optional[str] = Field(None, description="Location text address.") 32 | lat: t.Optional[float] = Field( 33 | None, description="Geocentric latitude of the Location." 34 | ) 35 | lng: t.Optional[float] = Field( 36 | None, description="Geocentric longitude of the Location." 37 | ) 38 | _fields: t.Optional[LocationFields] = Field( 39 | None, 40 | alias="fields", 41 | description="Other location attributes like country, country_code etc", 42 | ) 43 | 44 | 45 | class GeneralEntitySchema(BaseModel): 46 | name: str = Field(..., description="Identification name of the Object") 47 | value: t.Optional[str] = Field( 48 | None, description="Value associated to the Object's name" 49 | ) 50 | 51 | 52 | class Label(BaseModel): 53 | board_key: str = Field( 54 | ..., description="Identification key of the Board attached to the Job." 55 | ) 56 | job_key: str = Field(None, description="Identification key of the Job.") 57 | job_reference: t.Optional[str] = Field( 58 | None, description="Custom identifier of the Job." 59 | ) 60 | stage: str = Field(..., description="Stage of the job") 61 | date_stage: str = Field(..., description="Date when the job reached this stage") 62 | rating: int = Field(..., description="Rating associated with the job") 63 | date_rating: str = Field(..., description="Date when the rating was given") 64 | 65 | 66 | class Skill(BaseModel): 67 | name: str = Field(..., description="Identification name of the skill") 68 | type: t.Optional[str] = Field(None, description="Type of the skill. hard or soft") 69 | value: t.Optional[str] = Field(None, description="Value associated to the skill") 70 | 71 | 72 | # Job 73 | class Section(BaseModel): 74 | name: t.Optional[str] = Field( 75 | None, 76 | description="Identification name of a Section of the Job. Example: culture", 77 | ) 78 | title: t.Optional[str] = Field( 79 | None, description="Display Title of a Section. Example: Corporate Culture" 80 | ) 81 | description: t.Optional[str] = Field( 82 | None, description="Text description of a Section: Example: Our values areNone" 83 | ) 84 | 85 | 86 | class RangesFloat(BaseModel): 87 | name: t.Optional[str] = Field( 88 | None, 89 | description=( 90 | "Identification name of a Range of floats attached " 91 | "to the Job. Example: salary" 92 | ), 93 | ) 94 | value_min: t.Optional[float] = Field(None, description="Min value. Example: 500.") 95 | value_max: t.Optional[float] = Field(None, description="Max value. Example: 100.") 96 | unit: t.Optional[str] = Field( 97 | None, description="Unit of the value. Example: euros." 98 | ) 99 | 100 | 101 | class RangesDate(BaseModel): 102 | name: t.Optional[str] = Field( 103 | None, 104 | description=( 105 | "Identification name of a Range of dates attached" 106 | " to the Job. Example: availability." 107 | ), 108 | ) 109 | value_min: t.Optional[str] = Field( 110 | None, description="Min value in datetime ISO 8601, Example: 500." 111 | ) 112 | value_max: t.Optional[str] = Field( 113 | None, description="Max value in datetime ISO 8601, Example: 1000" 114 | ) 115 | 116 | 117 | class Board(BaseModel): 118 | key: str = Field(..., description="Identification key of the Board.") 119 | name: str = Field(..., description="Name of the Board.") 120 | type: str = Field(..., description="Type of the Board, Example: api, folder") 121 | subtype: str = Field( 122 | ..., description="Subtype of the Board, Example: python, excel" 123 | ) 124 | environment: str = Field( 125 | ..., description="Environment of the Board, Example: production, staging, test" 126 | ) 127 | 128 | 129 | class HrFlowJob(BaseModel): 130 | key: str = Field(None, description="Identification key of the Job.") 131 | reference: t.Optional[str] = Field( 132 | None, description="Custom identifier of the Job." 133 | ) 134 | name: str = Field(..., description="Job title.") 135 | board_key: str = Field( 136 | ..., description="Identification key of the Board attached to the Job." 137 | ) 138 | location: Location = Field(..., description="Job location object.") 139 | sections: t.List[Section] = Field(None, description="Job custom sections.") 140 | culture: t.Optional[str] = Field( 141 | None, description="Describes the company's values, work environment, and ethos." 142 | ) 143 | benefits: t.Optional[str] = Field( 144 | None, description="Lists the perks and advantages offered to employees." 145 | ) 146 | responsibilities: t.Optional[str] = Field( 147 | None, description="Outlines the duties and tasks expected from the role." 148 | ) 149 | requirements: t.Optional[str] = Field( 150 | None, 151 | description="Specifies the qualifications and skills needed for the position.", 152 | ) 153 | interviews: t.Optional[str] = Field( 154 | None, description="Provides information about the interview process and stages." 155 | ) 156 | url: t.Optional[str] = Field(None, description="Job post original URL.") 157 | summary: t.Optional[str] = Field(None, description="Brief summary of the Job.") 158 | board: t.Optional[Board] 159 | archived_at: t.Optional[str] = Field( 160 | None, 161 | description=( 162 | "type: datetime ISO8601, Archive date of the Job. " 163 | "The value is null for unarchived Jobs." 164 | ), 165 | ) 166 | updated_at: str = Field( 167 | None, description="type: datetime ISO8601, Last update date of the Job." 168 | ) 169 | created_at: t.Optional[str] = Field( 170 | None, description="type: datetime ISO8601, Creation date of the Job." 171 | ) 172 | skills: t.Optional[t.List[Skill]] = Field( 173 | None, description="List of skills of the Job." 174 | ) 175 | languages: t.Optional[t.List[GeneralEntitySchema]] = Field( 176 | None, description="List of spoken languages of the Job" 177 | ) 178 | certifications: t.Optional[t.List[GeneralEntitySchema]] = Field( 179 | None, description="List of certifications of the Job." 180 | ) 181 | courses: t.Optional[t.List[GeneralEntitySchema]] = Field( 182 | None, description="List of courses of the Job" 183 | ) 184 | tasks: t.Optional[t.List[GeneralEntitySchema]] = Field( 185 | None, description="List of tasks of the Job" 186 | ) 187 | tags: t.Optional[t.List[GeneralEntitySchema]] = Field( 188 | None, description="List of tags of the Job" 189 | ) 190 | metadatas: t.Optional[t.List[GeneralEntitySchema]] = Field( 191 | None, description="List of metadatas of the Job" 192 | ) 193 | ranges_float: t.Optional[t.List[RangesFloat]] = Field( 194 | None, description="List of ranges of floats" 195 | ) 196 | ranges_date: t.Optional[t.List[RangesDate]] = Field( 197 | None, description="List of ranges of dates" 198 | ) 199 | 200 | 201 | # Profile 202 | class Url(BaseModel): 203 | type: t.Optional[ 204 | t.Literal["from_resume", "linkedin", "twitter", "facebook", "github"] 205 | ] 206 | url: t.Optional[str] 207 | 208 | 209 | class ProfileInfo(BaseModel): 210 | full_name: t.Optional[str] = Field(None, description="Profile full name") 211 | first_name: t.Optional[str] = Field(None, description="Profile first name") 212 | last_name: t.Optional[str] = Field(None, description="Profile last name") 213 | email: t.Optional[str] = Field(None, description="Profile email") 214 | phone: t.Optional[str] = Field(None, description="Profile phone number") 215 | date_birth: t.Optional[str] = Field(None, description="Profile date of birth") 216 | location: t.Optional[Location] = Field(None, description="Profile location object") 217 | urls: t.Optional[t.List[Url]] = Field( 218 | None, description="Profile social networks and URLs" 219 | ) 220 | picture: t.Optional[str] = Field(None, description="Profile picture url") 221 | gender: t.Optional[str] = Field(None, description="Profile gender") 222 | summary: t.Optional[str] = Field(None, description="Profile summary text") 223 | 224 | 225 | class Experience(BaseModel): 226 | key: t.Optional[str] = Field( 227 | None, description="Identification key of the Experience." 228 | ) 229 | company: t.Optional[str] = Field( 230 | None, description="Company name of the Experience." 231 | ) 232 | logo: t.Optional[str] = Field(None, description="Logo of the Company.") 233 | title: t.Optional[str] = Field(None, description="Title of the Experience.") 234 | description: t.Optional[str] = Field( 235 | None, description="Description of the Experience." 236 | ) 237 | location: t.Optional[Location] = Field( 238 | None, description="Location object of the Experience." 239 | ) 240 | date_start: t.Optional[str] = Field( 241 | None, description="Start date of the experience. type: ('datetime ISO 8601')" 242 | ) 243 | date_end: t.Optional[str] = Field( 244 | None, description="End date of the experience. type: ('datetime ISO 8601')" 245 | ) 246 | skills: t.Optional[t.List[Skill]] = Field( 247 | None, description="List of skills of the Experience." 248 | ) 249 | certifications: t.Optional[t.List[GeneralEntitySchema]] = Field( 250 | None, description="List of certifications of the Experience." 251 | ) 252 | courses: t.Optional[t.List[GeneralEntitySchema]] = Field( 253 | None, description="List of courses of the Experience." 254 | ) 255 | tasks: t.Optional[t.List[GeneralEntitySchema]] = Field( 256 | None, description="List of tasks of the Experience." 257 | ) 258 | languages: t.Optional[t.List[GeneralEntitySchema]] = Field( 259 | None, description="List of spoken languages of the profile" 260 | ) 261 | interests: t.Optional[t.List[GeneralEntitySchema]] = Field( 262 | None, description="List of interests of the Experience." 263 | ) 264 | 265 | 266 | class Education(BaseModel): 267 | key: t.Optional[str] = Field( 268 | None, description="Identification key of the Education." 269 | ) 270 | school: t.Optional[str] = Field(None, description="School name of the Education.") 271 | logo: t.Optional[str] = Field(None, description="Logo of the School.") 272 | title: t.Optional[str] = Field(None, description="Title of the Education.") 273 | description: t.Optional[str] = Field( 274 | None, description="Description of the Education." 275 | ) 276 | location: t.Optional[Location] = Field( 277 | None, description="Location object of the Education." 278 | ) 279 | date_start: t.Optional[str] = Field( 280 | None, description="Start date of the Education. type: ('datetime ISO 8601')" 281 | ) 282 | date_end: t.Optional[str] = Field( 283 | None, description="End date of the Education. type: ('datetime ISO 8601')" 284 | ) 285 | skills: t.Optional[t.List[Skill]] = Field( 286 | None, description="List of skills of the Education." 287 | ) 288 | certifications: t.Optional[t.List[GeneralEntitySchema]] = Field( 289 | None, description="List of certifications of the Education." 290 | ) 291 | courses: t.Optional[t.List[GeneralEntitySchema]] = Field( 292 | None, description="List of courses of the Education." 293 | ) 294 | tasks: t.Optional[t.List[GeneralEntitySchema]] = Field( 295 | None, description="List of tasks of the Education." 296 | ) 297 | languages: t.Optional[t.List[GeneralEntitySchema]] = Field( 298 | None, description="List of spoken languages of the profile" 299 | ) 300 | interests: t.Optional[t.List[GeneralEntitySchema]] = Field( 301 | None, description="List of interests of the Experience." 302 | ) 303 | 304 | 305 | class Attachment(BaseModel): 306 | type: t.Optional[str] 307 | alt: t.Optional[str] 308 | file_size: t.Optional[str] 309 | file_name: t.Optional[str] 310 | original_file_name: t.Optional[str] 311 | extension: t.Optional[str] 312 | public_url: t.Optional[str] 313 | updated_at: t.Optional[str] 314 | created_at: t.Optional[str] 315 | 316 | 317 | class HrFlowProfile(BaseModel): 318 | key: str = Field(None, description="Identification key of the Profile.") 319 | reference: t.Optional[str] = Field( 320 | None, description="Custom identifier of the Profile." 321 | ) 322 | info: ProfileInfo = Field(..., description="Object containing the Profile's info.") 323 | text_language: t.Optional[str] = Field( 324 | None, description="Code language of the Profile. type: string code ISO 639-1" 325 | ) 326 | text: str = Field(..., description="Full text of the Profile..") 327 | archived_at: t.Optional[str] = Field( 328 | None, 329 | description=( 330 | "type: datetime ISO8601, Archive date of the Profile." 331 | " The value is null for unarchived Profiles." 332 | ), 333 | ) 334 | updated_at: str = Field( 335 | None, description="type: datetime ISO8601, Last update date of the Profile." 336 | ) 337 | created_at: str = Field( 338 | None, description="type: datetime ISO8601, Creation date of the Profile." 339 | ) 340 | experiences_duration: float = Field( 341 | None, description="Total number of years of experience." 342 | ) 343 | educations_duration: float = Field( 344 | None, description="Total number of years of education." 345 | ) 346 | experiences: t.Optional[t.List[Experience]] = Field( 347 | None, description="List of experiences of the Profile." 348 | ) 349 | educations: t.Optional[t.List[Education]] = Field( 350 | None, description="List of educations of the Profile." 351 | ) 352 | attachments: t.List[Attachment] = Field( 353 | None, description="List of documents attached to the Profile." 354 | ) 355 | skills: t.Optional[t.List[Skill]] = Field( 356 | None, description="List of skills of the Profile." 357 | ) 358 | languages: t.Optional[t.List[GeneralEntitySchema]] = Field( 359 | None, description="List of spoken languages of the profile" 360 | ) 361 | certifications: t.Optional[t.List[GeneralEntitySchema]] = Field( 362 | None, description="List of certifications of the Profile." 363 | ) 364 | courses: t.Optional[t.List[GeneralEntitySchema]] = Field( 365 | None, description="List of courses of the Profile." 366 | ) 367 | tasks: t.Optional[t.List[GeneralEntitySchema]] = Field( 368 | None, description="List of tasks of the Profile." 369 | ) 370 | interests: t.Optional[t.List[GeneralEntitySchema]] = Field( 371 | None, description="List of interests of the Profile." 372 | ) 373 | tags: t.Optional[t.List[GeneralEntitySchema]] = Field( 374 | None, description="List of tags of the Profile." 375 | ) 376 | metadatas: t.Optional[t.List[GeneralEntitySchema]] = Field( 377 | None, description="List of metadatas of the Profile." 378 | ) 379 | labels: t.Optional[t.List[Label]] = Field( 380 | None, description="List of labels of the Profile." 381 | ) 382 | -------------------------------------------------------------------------------- /examples/job/job_endpoints_examples.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "attachments": {}, 5 | "cell_type": "markdown", 6 | "metadata": {}, 7 | "source": [ 8 | "# Setup the config and authentication" 9 | ] 10 | }, 11 | { 12 | "cell_type": "code", 13 | "execution_count": 1, 14 | "metadata": {}, 15 | "outputs": [], 16 | "source": [ 17 | "from getpass import getpass\n", 18 | "import os\n", 19 | "import json \n", 20 | "from hrflow import Hrflow\n", 21 | "\n", 22 | "# # Get API credentials from environment variables\n", 23 | "api_secret = getpass(\"Enter your API secret: \")\n", 24 | "board_key = getpass(\"Enter your board key: \")\n", 25 | "user_email = getpass(\"Enter your user email: \")" 26 | ] 27 | }, 28 | { 29 | "attachments": {}, 30 | "cell_type": "markdown", 31 | "metadata": {}, 32 | "source": [ 33 | "# Setup HrFlow.ai Client " 34 | ] 35 | }, 36 | { 37 | "cell_type": "code", 38 | "execution_count": 4, 39 | "metadata": {}, 40 | "outputs": [], 41 | "source": [ 42 | "client = Hrflow(api_secret=api_secret, api_user=user_email)" 43 | ] 44 | }, 45 | { 46 | "attachments": {}, 47 | "cell_type": "markdown", 48 | "metadata": {}, 49 | "source": [ 50 | "# 1 - Read a Job in a Board in HrFlow.ai " 51 | ] 52 | }, 53 | { 54 | "attachments": {}, 55 | "cell_type": "markdown", 56 | "metadata": {}, 57 | "source": [ 58 | "### a - Using external ID set : `reference` in HrFlow.ai" 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": null, 64 | "metadata": {}, 65 | "outputs": [], 66 | "source": [ 67 | "reference = \"a2n1j000000g0AAAAY\" # <-- Replace with your job reference\n", 68 | "\n", 69 | "# Get job by reference\n", 70 | "response = client.job.storing.get(board_key=board_key, reference=reference)\n", 71 | "\n", 72 | "response" 73 | ] 74 | }, 75 | { 76 | "attachments": {}, 77 | "cell_type": "markdown", 78 | "metadata": {}, 79 | "source": [ 80 | "### b - Using profile key (internal set by HrFlow.ai) : `key` in HrFlow.ai" 81 | ] 82 | }, 83 | { 84 | "cell_type": "code", 85 | "execution_count": null, 86 | "metadata": {}, 87 | "outputs": [], 88 | "source": [ 89 | "key = \"FILL THIS\" # <-- Replace with your job key\n", 90 | "\n", 91 | "# Get job by reference\n", 92 | "response = client.job.storing.get(board_key=board_key, key=key)\n", 93 | "\n", 94 | "response" 95 | ] 96 | }, 97 | { 98 | "attachments": {}, 99 | "cell_type": "markdown", 100 | "metadata": {}, 101 | "source": [ 102 | "# 2 - Write a job in a board from a Structured data " 103 | ] 104 | }, 105 | { 106 | "cell_type": "code", 107 | "execution_count": 8, 108 | "metadata": {}, 109 | "outputs": [ 110 | { 111 | "data": { 112 | "text/plain": [ 113 | "{'code': 201,\n", 114 | " 'message': 'Job created',\n", 115 | " 'data': {'id': 1245507,\n", 116 | " 'key': '4f2e11266a11e728e794dc5f0867462fa60321ba',\n", 117 | " 'reference': 'my_custom_reference',\n", 118 | " 'board_key': '7fce016712fa373456ef279c297da5009a2020d9',\n", 119 | " 'board': {'key': '7fce016712fa373456ef279c297da5009a2020d9',\n", 120 | " 'name': 'vulcain_test',\n", 121 | " 'type': 'api',\n", 122 | " 'subtype': 'python',\n", 123 | " 'environment': 'production'},\n", 124 | " 'name': 'Data Engineer',\n", 125 | " 'url': 'https://www.pole-emploi.ai/jobs/data_engineer',\n", 126 | " 'picture': None,\n", 127 | " 'summary': 'As an engineer for the Data Engineering Infrastructure team, you will design, build, scale, and evolve our data engineering platform, services and tooling. Your work will have a critical impact on all areas of business:supporting detailed internal analytics, calculating customer usage, securing our platform, and much more.',\n", 128 | " 'location': {'text': 'Dampierre en Burly (45)',\n", 129 | " 'lat': None,\n", 130 | " 'lng': None,\n", 131 | " 'gmaps': None,\n", 132 | " 'fields': None},\n", 133 | " 'archive': None,\n", 134 | " 'archived_at': None,\n", 135 | " 'updated_at': '2023-06-20T15:50:32+0000',\n", 136 | " 'created_at': '2023-06-20T15:50:32+0000',\n", 137 | " 'sections': [{'name': 'section 1',\n", 138 | " 'title': 'title section 1',\n", 139 | " 'description': 'text section 1'}],\n", 140 | " 'culture': 'FILL THIS WITH A TEXT ABOUT YOUR COMPANY CULTURE',\n", 141 | " 'responsibilities': 'FILL THIS WITH A TEXT ABOUT THE RESPONSIBILITIES OF THE JOB',\n", 142 | " 'requirements': 'FILL THIS WITH A TEXT ABOUT THE REQUIREMENTS OF THE JOB',\n", 143 | " 'benefits': 'FILL THIS WITH A TEXT ABOUT THE BENEFITS OF THE JOB',\n", 144 | " 'interviews': 'FILL THIS WITH A TEXT ABOUT THE INTERVIEWS OF THE JOB',\n", 145 | " 'skills': [{'name': 'python', 'value': None, 'type': 'hard'},\n", 146 | " {'name': 'spark', 'value': 0.9, 'type': 'hard'}],\n", 147 | " 'languages': [{'name': 'english', 'value': None},\n", 148 | " {'name': 'french', 'value': None}],\n", 149 | " 'certifications': None,\n", 150 | " 'courses': None,\n", 151 | " 'tasks': None,\n", 152 | " 'tags': [{'name': 'company', 'value': 'Google'}],\n", 153 | " 'metadatas': [{'name': 'metadata example', 'value': 'metadata'}],\n", 154 | " 'ranges_float': [{'name': 'salary',\n", 155 | " 'value_min': 45000,\n", 156 | " 'value_max': 50000,\n", 157 | " 'unit': 'eur'}],\n", 158 | " 'ranges_date': [{'name': 'interview_dates',\n", 159 | " 'value_min': '2023-05-18T21:59',\n", 160 | " 'value_max': '2023-09-15T21:59'}]}}" 161 | ] 162 | }, 163 | "execution_count": 8, 164 | "metadata": {}, 165 | "output_type": "execute_result" 166 | } 167 | ], 168 | "source": [ 169 | "input_data = {\n", 170 | " \"name\": \"Data Engineer\",\n", 171 | " \"reference\": \"my_custom_reference\", # <-- Replace with your job reference\n", 172 | " \"url\": \"https://www.pole-emploi.ai/jobs/data_engineer\",\n", 173 | " \"summary\": \"As an engineer for the Data Engineering Infrastructure team, you will design, build, scale, and evolve our data engineering platform, services and tooling. Your work will have a critical impact on all areas of business:supporting detailed internal analytics, calculating customer usage, securing our platform, and much more.\",\n", 174 | " \"location\": {\n", 175 | " \"text\": \"Dampierre en Burly (45)\",\n", 176 | " \"geopoint\": {\n", 177 | " \"lat\": 47.7667,\n", 178 | " \"lon\": 2.5167\n", 179 | " }\n", 180 | " },\n", 181 | " \"culture\": \"FILL THIS WITH A TEXT ABOUT YOUR COMPANY CULTURE\",\n", 182 | " \"responsibilities\": \"FILL THIS WITH A TEXT ABOUT THE RESPONSIBILITIES OF THE JOB\",\n", 183 | " \"requirements\": \"FILL THIS WITH A TEXT ABOUT THE REQUIREMENTS OF THE JOB\",\n", 184 | " \"benefits\": \"FILL THIS WITH A TEXT ABOUT THE BENEFITS OF THE JOB\",\n", 185 | " \"interviews\": \"FILL THIS WITH A TEXT ABOUT THE INTERVIEWS OF THE JOB\",\n", 186 | " \"sections\": [{ # <-- Sections are mandatory, leave as empty list if no section is provided\n", 187 | " \"name\": \"section 1\",\n", 188 | " \"title\": \"title section 1\",\n", 189 | " \"description\": \"text section 1\"\n", 190 | " }\n", 191 | " ],\n", 192 | " \"skills\": [{\n", 193 | " \"name\": \"python\",\n", 194 | " \"value\": None, # <-- Leave None if no assessment to skills is provided\n", 195 | " \"type\": \"hard\"\n", 196 | " },\n", 197 | " {\n", 198 | " \"name\": \"spark\",\n", 199 | " \"value\": 0.9,\n", 200 | " \"type\": \"hard\"\n", 201 | " }\n", 202 | " ],\n", 203 | " \"languages\": [{\n", 204 | " \"name\": \"english\",\n", 205 | " \"value\": None\n", 206 | " },\n", 207 | " { \n", 208 | " \"name\": \"french\",\n", 209 | " \"value\": None\n", 210 | " }\n", 211 | " ],\n", 212 | " \"tags\": [{\n", 213 | " \"name\": \"company\", # <-- Other custom fields can be added as tags\n", 214 | " \"value\": \"Google\"\n", 215 | " }\n", 216 | " ],\n", 217 | " \"ranges_date\": [{\n", 218 | " \"name\": \"interview_dates\",\n", 219 | " \"value_min\": \"2023-05-18T21:59\",\n", 220 | " \"value_max\": \"2023-09-15T21:59\"\n", 221 | " }\n", 222 | " ],\n", 223 | " \"ranges_float\": [{\n", 224 | " \"name\": \"salary\",\n", 225 | " \"value_min\": 45000,\n", 226 | " \"value_max\": 50000,\n", 227 | " \"unit\": \"eur\"\n", 228 | " }\n", 229 | " ],\n", 230 | " \"metadatas\": [{\n", 231 | " \"name\": \"metadata example\",\n", 232 | " \"value\": \"metadata\"\n", 233 | " }\n", 234 | " ]\n", 235 | " }\n", 236 | "\n", 237 | "response = client.job.storing.add_json(board_key=board_key, job_json=input_data)\n", 238 | "\n", 239 | "response" 240 | ] 241 | }, 242 | { 243 | "attachments": {}, 244 | "cell_type": "markdown", 245 | "metadata": {}, 246 | "source": [ 247 | "# 3 - Archive a Job already in a Board " 248 | ] 249 | }, 250 | { 251 | "attachments": {}, 252 | "cell_type": "markdown", 253 | "metadata": {}, 254 | "source": [ 255 | "### a - Archive a Job from a Board" 256 | ] 257 | }, 258 | { 259 | "cell_type": "code", 260 | "execution_count": 9, 261 | "metadata": {}, 262 | "outputs": [ 263 | { 264 | "data": { 265 | "text/plain": [ 266 | "{'code': 200,\n", 267 | " 'message': 'Job archived',\n", 268 | " 'data': {'key': '4f2e11266a11e728e794dc5f0867462fa60321ba'}}" 269 | ] 270 | }, 271 | "execution_count": 9, 272 | "metadata": {}, 273 | "output_type": "execute_result" 274 | } 275 | ], 276 | "source": [ 277 | "# Let's archive the job with reference \"my_custom_reference\"\n", 278 | "reference = \"my_custom_reference\" # <-- Replace with your job reference\n", 279 | "\n", 280 | "response = client.job.storing.archive(board_key=board_key,\n", 281 | " reference=reference)\n", 282 | "\n", 283 | "response" 284 | ] 285 | }, 286 | { 287 | "attachments": {}, 288 | "cell_type": "markdown", 289 | "metadata": {}, 290 | "source": [ 291 | "# 5 - Edit a Job already in a Board " 292 | ] 293 | }, 294 | { 295 | "cell_type": "code", 296 | "execution_count": 7, 297 | "metadata": {}, 298 | "outputs": [ 299 | { 300 | "data": { 301 | "text/plain": [ 302 | "{'code': 200,\n", 303 | " 'message': 'Job edited',\n", 304 | " 'data': {'id': 1264450,\n", 305 | " 'key': '8cee3ead87a38cc1f7f3cf9298b7e58e16406e05',\n", 306 | " 'reference': 'my_custom_reference',\n", 307 | " 'board_key': '7fce016712fa373456ef279c297da5009a2020d9',\n", 308 | " 'board': {'key': '7fce016712fa373456ef279c297da5009a2020d9',\n", 309 | " 'name': 'vulcain_test',\n", 310 | " 'type': 'api',\n", 311 | " 'subtype': 'python',\n", 312 | " 'environment': 'production'},\n", 313 | " 'name': 'Data Engineer',\n", 314 | " 'url': 'https://www.pole-emploi.ai/jobs/data_engineer',\n", 315 | " 'picture': None,\n", 316 | " 'summary': 'As an engineer for the Data Engineering Infrastructure team, you will design, build, scale, and evolve our data engineering platform, services and tooling. Your work will have a critical impact on all areas of business:supporting detailed internal analytics, calculating customer usage, securing our platform, and much more.',\n", 317 | " 'location': {'text': 'Dampierre en Burly (45)',\n", 318 | " 'lat': None,\n", 319 | " 'lng': None,\n", 320 | " 'gmaps': None,\n", 321 | " 'fields': []},\n", 322 | " 'archive': None,\n", 323 | " 'archived_at': None,\n", 324 | " 'updated_at': '2023-06-21T15:54:45+0000',\n", 325 | " 'created_at': '2023-06-21T15:54:45+0000',\n", 326 | " 'sections': [{'name': 'section 1',\n", 327 | " 'title': 'title section 1',\n", 328 | " 'description': 'text section 1'}],\n", 329 | " 'culture': 'This is a New CULTURE 123!!',\n", 330 | " 'responsibilities': 'FILL THIS WITH A TEXT ABOUT THE RESPONSIBILITIES OF THE JOB',\n", 331 | " 'requirements': 'FILL THIS WITH A TEXT ABOUT THE REQUIREMENTS OF THE JOB',\n", 332 | " 'benefits': 'FILL THIS WITH A TEXT ABOUT THE BENEFITS OF THE JOB',\n", 333 | " 'interviews': 'FILL THIS WITH A TEXT ABOUT THE INTERVIEWS OF THE JOB',\n", 334 | " 'skills': [{'name': 'python', 'value': None, 'type': 'hard'},\n", 335 | " {'name': 'spark', 'value': 0.9, 'type': 'hard'}],\n", 336 | " 'languages': [{'name': 'english', 'value': None},\n", 337 | " {'name': 'french', 'value': None}],\n", 338 | " 'certifications': [],\n", 339 | " 'courses': [],\n", 340 | " 'tasks': [],\n", 341 | " 'tags': [{'name': 'company', 'value': 'Google'}],\n", 342 | " 'metadatas': [{'name': 'metadata example', 'value': 'metadata'}],\n", 343 | " 'ranges_float': [{'name': 'salary',\n", 344 | " 'value_min': 45000,\n", 345 | " 'value_max': 50000,\n", 346 | " 'unit': 'eur'}],\n", 347 | " 'ranges_date': [{'name': 'interview_dates',\n", 348 | " 'value_min': '2023-05-18T21:59',\n", 349 | " 'value_max': '2023-09-15T21:59'}]}}" 350 | ] 351 | }, 352 | "execution_count": 7, 353 | "metadata": {}, 354 | "output_type": "execute_result" 355 | } 356 | ], 357 | "source": [ 358 | "# Edit a job : edit is a Put not a Patch so you need to provide all the fields\n", 359 | "\n", 360 | "# let's get the job by reference\n", 361 | "reference = \"my_custom_reference\" # <-- Replace with your job reference\n", 362 | "\n", 363 | "response = client.job.storing.get(board_key=board_key, reference=reference)\n", 364 | "\n", 365 | "job = response['data']\n", 366 | "\n", 367 | "# edit a field or more \n", 368 | "job['culture'] = \"This is a New CULTURE 123!!\"\n", 369 | "\n", 370 | "# edit the job\n", 371 | "response = client.job.storing.edit(board_key=board_key,\n", 372 | " # key=job['key'], # This is Optional, fand left for backward compatibility\n", 373 | " job_json=job)\n", 374 | "\n", 375 | "response" 376 | ] 377 | }, 378 | { 379 | "cell_type": "code", 380 | "execution_count": 8, 381 | "metadata": {}, 382 | "outputs": [ 383 | { 384 | "data": { 385 | "text/plain": [ 386 | "'This is a New CULTURE !!'" 387 | ] 388 | }, 389 | "execution_count": 8, 390 | "metadata": {}, 391 | "output_type": "execute_result" 392 | } 393 | ], 394 | "source": [ 395 | "response[\"data\"][\"culture\"]" 396 | ] 397 | }, 398 | { 399 | "cell_type": "code", 400 | "execution_count": null, 401 | "metadata": {}, 402 | "outputs": [], 403 | "source": [] 404 | } 405 | ], 406 | "metadata": { 407 | "kernelspec": { 408 | "display_name": "python-hrflow-api-S_mz3ndj-py3.8", 409 | "language": "python", 410 | "name": "python3" 411 | }, 412 | "language_info": { 413 | "codemirror_mode": { 414 | "name": "ipython", 415 | "version": 3 416 | }, 417 | "file_extension": ".py", 418 | "mimetype": "text/x-python", 419 | "name": "python", 420 | "nbconvert_exporter": "python", 421 | "pygments_lexer": "ipython3", 422 | "version": "3.10.5" 423 | }, 424 | "orig_nbformat": 4 425 | }, 426 | "nbformat": 4, 427 | "nbformat_minor": 2 428 | } 429 | --------------------------------------------------------------------------------