Files

501 lines
20 KiB
Python
Raw Permalink Normal View History

"""
Maps the repaired CSV records onto the relational model.
Design notes
------------
* **Idempotent.** The natural key is the LinkedIn id (falling back to the
profile URL), so re-running the import updates instead of duplicating.
* **Bulk friendly.** Companies, schools and skills are resolved through
in-memory caches, and the child rows of a profile are replaced wholesale with
``bulk_create`` - 284 profiles import in a couple of seconds.
* **Self healing input.** The top level columns of this export are frequently
missing, but the same information usually exists inside the nested
``experience`` blob. ``_derive_current_position`` falls back to the primary
experience entry, which raises coverage of ``job_title_role`` from 108 to 183
profiles and of the current employer from 219 to 271.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from typing import Any
from django.contrib.postgres.search import SearchVector
from django.db import transaction
from apps.profiles.dataset import DatasetReader, ImportReport, parse_literal
from apps.profiles.models import (
Company,
Education,
Experience,
Profile,
ProfileSkill,
School,
Skill,
)
logger = logging.getLogger(__name__)
_YEAR_RE = re.compile(r"(\d{4})")
_MAX_SKILLS = 60
@dataclass
class ImportStats:
created: int = 0
updated: int = 0
skills: int = 0
companies: int = 0
schools: int = 0
experiences: int = 0
educations: int = 0
report: ImportReport = field(default_factory=ImportReport)
def as_dict(self) -> dict[str, Any]:
return {
"profiles_created": self.created,
"profiles_updated": self.updated,
"skills": self.skills,
"companies": self.companies,
"schools": self.schools,
"experiences": self.experiences,
"educations": self.educations,
**self.report.as_dict(),
}
class ProfileImporter:
"""Loads the dataset into PostgreSQL."""
def __init__(self, path: str, limit: int | None = None) -> None:
self.path = path
self.limit = limit
self._companies: dict[str, Company] = {}
self._schools: dict[str, School] = {}
self._skills: dict[str, Skill] = {}
# -- Entry point --------------------------------------------------------
def run(self) -> ImportStats:
records, report = DatasetReader(self.path).read()
if self.limit:
records = records[: self.limit]
stats = ImportStats(report=report)
self._warm_caches()
for record in records:
try:
with transaction.atomic():
self._import_record(record, stats)
except Exception: # noqa: BLE001 - one bad row must not abort the run
logger.exception(
"profile_import_failed",
extra={"event": "profile_import_failed", "name": record.get("full_name")},
)
report.issues.append(record.get("full_name", "<unknown>"))
self._refresh_skill_counters()
self._refresh_search_vectors()
stats.skills = len(self._skills)
stats.companies = len(self._companies)
stats.schools = len(self._schools)
return stats
# -- Caches -------------------------------------------------------------
def _warm_caches(self) -> None:
self._companies = {c.name: c for c in Company.objects.all()}
self._schools = {s.name: s for s in School.objects.all()}
self._skills = {s.slug: s for s in Skill.objects.all()}
# -- Per record ---------------------------------------------------------
def _import_record(self, record: dict[str, str], stats: ImportStats) -> None:
experiences = parse_literal(record.get("experience"), [])
educations = parse_literal(record.get("education"), [])
primary = self._primary_experience(experiences)
title, role, sub_role, levels = self._derive_current_position(record, primary)
company = self._resolve_current_company(record, primary)
skills = self._clean_str_list(parse_literal(record.get("skills"), []))[:_MAX_SKILLS]
past_companies = [
str(item.get("company", {}).get("name") or "")
for item in experiences
if isinstance(item, dict) and isinstance(item.get("company"), dict)
]
schools = [
str(item.get("school", {}).get("name") or "")
for item in educations
if isinstance(item, dict) and isinstance(item.get("school"), dict)
]
majors = [
major
for item in educations
if isinstance(item, dict)
for major in self._clean_str_list(item.get("majors") or [])
]
defaults: dict[str, Any] = {
"full_name": record.get("full_name", "")[:255],
"first_name": record.get("first_name", "")[:128],
"last_name": record.get("last_name", "")[:128],
"gender": record.get("gender", "")[:16],
"linkedin_url": record.get("linkedin_url", "")[:255],
"linkedin_username": record.get("linkedin_username", "")[:128],
"github_url": record.get("github_url", "")[:255],
"twitter_url": record.get("twitter_url", "")[:255],
"job_title": title[:255],
"job_title_role": role[:64],
"job_title_sub_role": sub_role[:64],
"seniority_levels": levels,
"current_company": company,
"industry": (record.get("industry") or "")[:128],
"job_summary": record.get("job_summary", ""),
"job_start_date": record.get("job_start_date", "")[:10],
"location_name": record.get("location_name", "")[:255],
"location_locality": record.get("location_locality", "")[:128],
"location_region": record.get("location_region", "")[:128],
"location_country": record.get("location_country", "")[:128],
"location_continent": record.get("location_continent", "")[:64],
"summary": record.get("summary", ""),
"years_experience": self._to_int(record.get("inferred_years_experience"), 0, 70),
"inferred_salary": record.get("inferred_salary", "")[:32],
"connections": self._to_int(record.get("linkedin_connections"), 0, 100_000),
"interests": self._clean_str_list(parse_literal(record.get("interests"), []))[:40],
"languages": self._language_names(parse_literal(record.get("languages"), [])),
"emails": self._emails(parse_literal(record.get("emails"), [])),
"phone_numbers": self._clean_str_list(parse_literal(record.get("phone_numbers"), []))[
:10
],
"skills_text": " ".join(skills),
"companies_text": " ".join(
filter(None, {*past_companies, company.name if company else ""})
),
"education_text": " ".join(filter(None, [*schools, *majors])),
}
external_id = (record.get("linkedin_id") or "")[:64]
lookup = (
{"external_id": external_id}
if external_id
else {"linkedin_url": defaults["linkedin_url"]}
)
profile, created = Profile.objects.update_or_create(**lookup, defaults=defaults)
if created:
stats.created += 1
else:
stats.updated += 1
self._sync_skills(profile, skills)
stats.experiences += self._sync_experiences(profile, experiences)
stats.educations += self._sync_educations(profile, educations)
# -- Current position ---------------------------------------------------
@staticmethod
def _primary_experience(experiences: list[Any]) -> dict[str, Any]:
entries = [item for item in experiences if isinstance(item, dict)]
for item in entries:
if item.get("is_primary"):
return item
return entries[0] if entries else {}
def _derive_current_position(
self, record: dict[str, str], primary: dict[str, Any]
) -> tuple[str, str, str, list[str]]:
title_blob = primary.get("title") if isinstance(primary.get("title"), dict) else {}
title = record.get("job_title") or str(title_blob.get("name") or "")
role = record.get("job_title_role") or str(title_blob.get("role") or "")
sub_role = record.get("job_title_sub_role") or str(title_blob.get("sub_role") or "")
levels = self._clean_str_list(
parse_literal(record.get("job_title_levels"), None) or title_blob.get("levels") or []
)
return title, role, sub_role, levels[:5]
def _resolve_current_company(
self, record: dict[str, str], primary: dict[str, Any]
) -> Company | None:
name = (record.get("job_company_name") or "").strip()
payload: dict[str, Any] = {
"external_id": record.get("job_company_id", ""),
"industry": record.get("job_company_industry", ""),
"size": record.get("job_company_size", ""),
"founded": self._to_int(record.get("job_company_founded"), 1600, 2100),
"website": record.get("job_company_website", ""),
"linkedin_url": record.get("job_company_linkedin_url", ""),
"location_name": record.get("job_company_location_name", ""),
"country": record.get("job_company_location_country", ""),
}
if not name and isinstance(primary.get("company"), dict):
blob = primary["company"]
name = str(blob.get("name") or "").strip()
location = blob.get("location") if isinstance(blob.get("location"), dict) else {}
payload = {
"external_id": str(blob.get("id") or ""),
"industry": str(blob.get("industry") or ""),
"size": str(blob.get("size") or ""),
"founded": self._to_int(str(blob.get("founded") or ""), 1600, 2100),
"website": str(blob.get("website") or ""),
"linkedin_url": str(blob.get("linkedin_url") or ""),
"location_name": str(location.get("name") or ""),
"country": str(location.get("country") or ""),
}
return self._get_or_create_company(name, payload)
def _get_or_create_company(self, name: str, payload: dict[str, Any]) -> Company | None:
name = name.strip()[:255]
if not name:
return None
cached = self._companies.get(name)
if cached:
return cached
company = Company.objects.create(
name=name,
external_id=str(payload.get("external_id") or "")[:128],
industry=str(payload.get("industry") or "")[:128],
size=str(payload.get("size") or "")[:32],
founded=payload.get("founded"),
website=str(payload.get("website") or "")[:255],
linkedin_url=str(payload.get("linkedin_url") or "")[:255],
location_name=str(payload.get("location_name") or "")[:255],
country=str(payload.get("country") or "")[:128],
)
self._companies[name] = company
return company
def _get_or_create_school(self, blob: dict[str, Any]) -> School | None:
name = str(blob.get("name") or "").strip()[:255]
if not name:
return None
cached = self._schools.get(name)
if cached:
return cached
location = blob.get("location") if isinstance(blob.get("location"), dict) else {}
school = School.objects.create(
name=name,
type=str(blob.get("type") or "")[:64],
website=str(blob.get("website") or "")[:255],
linkedin_url=str(blob.get("linkedin_url") or "")[:255],
location_name=str(location.get("name") or "")[:255],
country=str(location.get("country") or "")[:128],
)
self._schools[name] = school
return school
# -- Children -----------------------------------------------------------
def _sync_skills(self, profile: Profile, skills: list[str]) -> None:
ProfileSkill.objects.filter(profile=profile).delete()
links: list[ProfileSkill] = []
used: set[int] = set()
for position, name in enumerate(skills):
slug = Skill.make_slug(name)
if not slug:
continue
skill = self._skills.get(slug)
if skill is None:
skill, _ = Skill.objects.get_or_create(slug=slug, defaults={"name": name[:128]})
self._skills[slug] = skill
if skill.pk in used:
continue
used.add(skill.pk)
links.append(ProfileSkill(profile=profile, skill=skill, position=position))
ProfileSkill.objects.bulk_create(links, ignore_conflicts=True)
def _sync_experiences(self, profile: Profile, experiences: list[Any]) -> int:
Experience.objects.filter(profile=profile).delete()
rows: list[Experience] = []
for item in experiences:
if not isinstance(item, dict):
continue
blob = item.get("company") if isinstance(item.get("company"), dict) else {}
title = item.get("title") if isinstance(item.get("title"), dict) else {}
company = self._get_or_create_company(
str(blob.get("name") or ""),
{
"external_id": blob.get("id"),
"industry": blob.get("industry"),
"size": blob.get("size"),
"founded": self._to_int(str(blob.get("founded") or ""), 1600, 2100),
"website": blob.get("website"),
"linkedin_url": blob.get("linkedin_url"),
"location_name": (
(blob.get("location") or {}).get("name")
if isinstance(blob.get("location"), dict)
else ""
),
"country": (
(blob.get("location") or {}).get("country")
if isinstance(blob.get("location"), dict)
else ""
),
},
)
rows.append(
Experience(
profile=profile,
company=company,
company_name=str(blob.get("name") or "")[:255],
title=str(title.get("name") or "")[:255],
title_role=str(title.get("role") or "")[:64],
title_sub_role=str(title.get("sub_role") or "")[:64],
levels=self._clean_str_list(title.get("levels") or [])[:5],
start_date=str(item.get("start_date") or "")[:10],
end_date=str(item.get("end_date") or "")[:10],
start_year=self._year(item.get("start_date")),
end_year=self._year(item.get("end_date")),
is_primary=bool(item.get("is_primary")),
location_name=self._first_location(item.get("location_names")),
summary=str(item.get("summary") or ""),
)
)
Experience.objects.bulk_create(rows)
return len(rows)
def _sync_educations(self, profile: Profile, educations: list[Any]) -> int:
Education.objects.filter(profile=profile).delete()
rows: list[Education] = []
for item in educations:
if not isinstance(item, dict):
continue
blob = item.get("school") if isinstance(item.get("school"), dict) else {}
rows.append(
Education(
profile=profile,
school=self._get_or_create_school(blob),
school_name=str(blob.get("name") or "")[:255],
degrees=self._clean_str_list(item.get("degrees") or [])[:5],
majors=self._clean_str_list(item.get("majors") or [])[:5],
minors=self._clean_str_list(item.get("minors") or [])[:5],
gpa=self._to_decimal(item.get("gpa")),
start_date=str(item.get("start_date") or "")[:10],
end_date=str(item.get("end_date") or "")[:10],
end_year=self._year(item.get("end_date")),
)
)
Education.objects.bulk_create(rows)
return len(rows)
# -- Post processing ----------------------------------------------------
@staticmethod
def _refresh_skill_counters() -> None:
"""Single UPDATE ... FROM keeps the facet ordering cheap at query time."""
from django.db import connection
with connection.cursor() as cursor:
cursor.execute(
"""
UPDATE profiles_skill AS s
SET profile_count = COALESCE(counts.total, 0)
FROM (
SELECT skill_id, COUNT(*) AS total
FROM profiles_profileskill
GROUP BY skill_id
) AS counts
WHERE counts.skill_id = s.id
"""
)
cursor.execute(
"""
UPDATE profiles_skill SET profile_count = 0
WHERE id NOT IN (SELECT skill_id FROM profiles_profileskill)
"""
)
@staticmethod
def _refresh_search_vectors() -> None:
"""
Rebuild the tsvector for every profile in one statement.
Weights encode the ranking intent: a hit on the person's name beats a
hit on their title or skills, which in turn beats a hit in the free-text
summary.
"""
Profile.objects.update(
search_vector=(
SearchVector("full_name", weight="A", config="english")
+ SearchVector("job_title", weight="B", config="english")
+ SearchVector("skills_text", weight="B", config="english")
+ SearchVector("companies_text", weight="B", config="english")
+ SearchVector("industry", weight="C", config="english")
+ SearchVector("education_text", weight="C", config="english")
+ SearchVector("location_name", weight="C", config="english")
+ SearchVector("summary", weight="D", config="english")
)
)
# -- Small helpers ------------------------------------------------------
@staticmethod
def _clean_str_list(values: Any) -> list[str]:
if not isinstance(values, list):
return []
seen: set[str] = set()
out: list[str] = []
for value in values:
if not isinstance(value, str):
continue
cleaned = value.strip().lower()[:128]
if cleaned and cleaned not in seen:
seen.add(cleaned)
out.append(cleaned)
return out
@staticmethod
def _language_names(values: Any) -> list[str]:
if not isinstance(values, list):
return []
return [
str(item.get("name")).strip().lower()[:64]
for item in values
if isinstance(item, dict) and item.get("name")
][:10]
@staticmethod
def _emails(values: Any) -> list[dict[str, str]]:
if not isinstance(values, list):
return []
return [
{
"address": str(item.get("address"))[:254],
"type": str(item.get("type") or "other")[:32],
}
for item in values
if isinstance(item, dict) and item.get("address")
][:10]
@staticmethod
def _first_location(values: Any) -> str:
if isinstance(values, list) and values and isinstance(values[0], str):
return values[0][:255]
return ""
@staticmethod
def _to_int(value: str | None, minimum: int, maximum: int) -> int | None:
if not value:
return None
try:
number = int(float(str(value).replace(",", "")))
except (TypeError, ValueError):
return None
return number if minimum <= number <= maximum else None
@staticmethod
def _to_decimal(value: Any) -> Any:
try:
number = float(value)
except (TypeError, ValueError):
return None
return round(number, 2) if 0 <= number <= 10 else None
@staticmethod
def _year(value: Any) -> int | None:
if not value:
return None
match = _YEAR_RE.search(str(value))
if not match:
return None
year = int(match.group(1))
return year if 1900 <= year <= 2100 else None