Files
resume-agent/backend/app/offerpai_resume.py
T

1357 lines
45 KiB
Python

"""OfferPai resume persistence client and v3 resume payload mapping.
The OfferPai token is accepted per call and is only forwarded as the upstream
``Token`` cookie. It is never retained on the client or included in errors.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from copy import deepcopy
from dataclasses import dataclass
import hashlib
import json
import re
from typing import Any, Literal, Protocol, TypeAlias
from uuid import uuid4
import httpx
from .resume_document import normalize_document
from .skill_classifier import classify_skills
from .validators import mask_phone
DEFAULT_OFFERPAI_RESUME_BASE_URL = "https://test.offerpai.com.cn/api"
_TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9._~-]{16,4096}$")
_RAW_PHONE_PATTERN = re.compile(r"^1[3-9]\d{9}$")
_MONTH_PATTERN = re.compile(r"^(\d{4})-(\d{2})$")
_PRESENT_VALUES = {"present", "current", "now", "至今"}
SectionKind: TypeAlias = Literal[
"education", "work", "internship", "project", "competition"
]
SUPPORTED_SECTION_KINDS: tuple[SectionKind, ...] = (
"education",
"work",
"internship",
"project",
"competition",
)
_SECTION_KIND_MAP: dict[str, SectionKind] = {
"education": "education",
"work": "work",
"work_experience": "work",
"internship": "internship",
"internship_experience": "internship",
"project": "project",
"project_experience": "project",
"competition": "competition",
}
_METADATA_SECTION_KINDS = {"skills", "certificates"}
_LOCAL_SECTION_KIND: dict[SectionKind, str] = {
"education": "education",
"work": "work_experience",
"internship": "internship_experience",
"project": "project_experience",
"competition": "competition",
}
_SECTION_HEADINGS: dict[str, str] = {
"education": "教育经历",
"work_experience": "工作经历",
"internship_experience": "实习经历",
"project_experience": "项目经历",
"competition": "竞赛获奖",
"certificates": "证书",
}
_LOCAL_ITEM_KEY_FIELDS: dict[str, tuple[str, ...]] = {
"education": ("school", "start_date"),
"work_experience": ("company", "position", "start_date"),
"internship_experience": ("company", "position", "start_date"),
"project_experience": ("project_name", "start_date"),
"competition": ("name", "award", "date"),
"certificates": ("value",),
}
_EXTERNAL_ENTRY_META = {
"id",
"provenance",
"pending_proposal",
"previous_version",
"gap_report",
"offerpai_record_id",
"offerpai_description_ids",
}
class OfferPaiResumeError(RuntimeError):
"""Stable, token-free error exposed by :class:`OfferPaiResumeClient`."""
def __init__(
self,
code: str,
public_message: str,
*,
status_code: int,
upstream_code: str | None = None,
) -> None:
super().__init__(public_message)
self.code = code
self.public_message = public_message
self.status_code = status_code
self.upstream_code = upstream_code
class OfferPaiResumeProvider(Protocol):
"""Interface consumed by the API layer for OfferPai resume persistence."""
def can_create(self, token: str) -> bool: ...
def list_resumes(self, token: str) -> list[dict[str, Any]]: ...
def get_main(
self, token: str, resume_id: str | int
) -> dict[str, Any]: ...
def list_section(
self,
token: str,
section: SectionKind,
*,
resume_id: str | int,
) -> list[dict[str, Any]]: ...
def save_main(self, token: str, payload: Mapping[str, Any]) -> str: ...
def replace_section(
self,
token: str,
section: SectionKind,
*,
resume_id: str | int | None,
items: Sequence[Mapping[str, Any]],
) -> str: ...
def delete_resume(self, token: str, resume_id: str | int) -> None: ...
class OfferPaiResumeClient:
"""Small synchronous client for the documented OfferPai resume endpoints."""
def __init__(
self,
base_url: str = DEFAULT_OFFERPAI_RESUME_BASE_URL,
*,
timeout_seconds: float = 8.0,
client: httpx.Client | None = None,
) -> None:
self.base_url = f"{base_url.rstrip('/')}/"
self.timeout_seconds = timeout_seconds
self._client = client
def can_create(self, token: str) -> bool:
data = self._request("GET", "resume/canCreate", token)
if not isinstance(data, bool):
raise _invalid_response("OfferPai returned an invalid canCreate response.")
return data
def list_resumes(self, token: str) -> list[dict[str, Any]]:
data = self._request("GET", "resume/list", token)
if not isinstance(data, list) or not all(isinstance(item, dict) for item in data):
raise _invalid_response("OfferPai returned an invalid resume list.")
normalized: list[dict[str, Any]] = []
for source in data:
item = deepcopy(source)
if item.get("id") is not None:
item["id"] = _normalize_id(item["id"])
if item.get("resumeId") is not None:
item["resumeId"] = _normalize_id(item["resumeId"])
if item.get("id") is None:
item["id"] = item["resumeId"]
normalized.append(item)
return normalized
def get_main(
self, token: str, resume_id: str | int
) -> dict[str, Any]:
data = self._request(
"GET",
"resume",
token,
params={"resumeId": _wire_id(resume_id)},
)
if not isinstance(data, dict):
raise _invalid_response("OfferPai returned an invalid resume main record.")
return _normalize_remote_record(data)
def list_section(
self,
token: str,
section: SectionKind,
*,
resume_id: str | int,
) -> list[dict[str, Any]]:
if section not in SUPPORTED_SECTION_KINDS:
raise ValueError(f"Unsupported OfferPai resume section: {section!r}")
data = self._request(
"GET",
f"resume/{section}",
token,
params={"resumeId": _wire_id(resume_id)},
)
if not isinstance(data, list) or not all(
isinstance(item, dict) for item in data
):
raise _invalid_response(
f"OfferPai returned an invalid {section} resume section."
)
return [_normalize_remote_record(item) for item in data]
def save_main(self, token: str, payload: Mapping[str, Any]) -> str:
body = deepcopy(dict(payload))
if body.get("resumeId") is None:
body.pop("resumeId", None)
else:
body["resumeId"] = _wire_id(body["resumeId"])
data = self._request("POST", "resume", token, json_body=body)
return _response_id(data, "resume")
def replace_section(
self,
token: str,
section: SectionKind,
*,
resume_id: str | int | None,
items: Sequence[Mapping[str, Any]],
) -> str:
if section not in SUPPORTED_SECTION_KINDS:
raise ValueError(f"Unsupported OfferPai resume section: {section!r}")
body: dict[str, Any] = {
"items": [deepcopy(dict(item)) for item in items],
}
if resume_id is not None:
body["resumeId"] = _wire_id(resume_id)
data = self._request("POST", f"resume/{section}", token, json_body=body)
return _response_id(data, f"{section} resume")
def delete_resume(self, token: str, resume_id: str | int) -> None:
data = self._request(
"POST",
"resume/delete",
token,
params={"resumeId": _wire_id(resume_id)},
)
if data is not None and data is not True and not isinstance(data, dict):
raise _invalid_response("OfferPai returned an invalid delete response.")
def _request(
self,
method: str,
path: str,
token: str,
*,
params: Mapping[str, Any] | None = None,
json_body: Mapping[str, Any] | None = None,
) -> Any:
normalized_token = _validate_token(token)
owned_client = self._client is None
client = self._client or httpx.Client(
base_url=self.base_url,
timeout=self.timeout_seconds,
follow_redirects=False,
headers={"Accept": "application/json"},
)
try:
response = client.request(
method,
path.lstrip("/"),
params=dict(params or {}),
json=dict(json_body) if json_body is not None else None,
headers={"Cookie": f"Token={normalized_token}"},
)
response.raise_for_status()
try:
payload = response.json()
except (ValueError, TypeError) as exc:
raise _invalid_response(
"OfferPai returned a non-JSON resume response."
) from exc
return _unwrap_response(payload)
except OfferPaiResumeError:
raise
except httpx.TimeoutException as exc:
raise OfferPaiResumeError(
"offerpai_resume_timeout",
"OfferPai resume service timed out. Please try again.",
status_code=504,
) from exc
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code
if status_code in {401, 403}:
raise OfferPaiResumeError(
"external_auth_invalid",
"OfferPai login credential is invalid or expired.",
status_code=401,
) from exc
if status_code == 404:
raise OfferPaiResumeError(
"offerpai_resume_not_found",
"The OfferPai resume or resume record was not found.",
status_code=404,
) from exc
if status_code == 409:
raise OfferPaiResumeError(
"offerpai_resume_conflict",
"The OfferPai resume was changed concurrently.",
status_code=409,
) from exc
if 400 <= status_code < 500:
raise OfferPaiResumeError(
"offerpai_resume_rejected",
"OfferPai rejected the resume request.",
status_code=status_code,
) from exc
raise OfferPaiResumeError(
"offerpai_resume_unavailable",
"OfferPai resume service is temporarily unavailable.",
status_code=502,
) from exc
except httpx.HTTPError as exc:
raise OfferPaiResumeError(
"offerpai_resume_unavailable",
"OfferPai resume service is temporarily unavailable.",
status_code=502,
) from exc
finally:
if owned_client:
client.close()
@dataclass(frozen=True, slots=True)
class OfferPaiResumePayloads:
"""Complete OfferPai payload set derived from one local resume snapshot."""
main: dict[str, Any]
sections: dict[SectionKind, dict[str, Any]]
unsupported_section_kinds: tuple[str, ...]
def build_offerpai_resume_payload(
content: Mapping[str, Any],
profile: Mapping[str, Any],
*,
resume_name: str,
resume_id: str | int | None = None,
) -> tuple[
dict[str, Any],
dict[SectionKind, dict[str, Any]],
tuple[str, ...],
]:
"""Build the three payload groups expected by the OfferPai API layer."""
payloads = map_v3_resume_to_offerpai(
content,
profile,
resume_id=resume_id,
resume_name=resume_name,
)
return payloads.main, payloads.sections, payloads.unsupported_section_kinds
def map_v3_resume_to_offerpai(
content: Mapping[str, Any],
session_profile: Mapping[str, Any],
*,
resume_id: str | int | None = None,
resume_name: str | None = None,
) -> OfferPaiResumePayloads:
"""Map local schema-v3 content and its session profile to OfferPai payloads.
The function is deterministic and does not mutate either input. Unknown
section kinds are returned to the caller instead of being silently dropped.
"""
basics = _mapping(content.get("basics"))
target = _mapping(content.get("target"))
external_account = _mapping(session_profile.get("external_account"))
tags = _mapping(session_profile.get("tags"))
section_items: dict[SectionKind, list[dict[str, Any]]] = {
kind: [] for kind in SUPPORTED_SECTION_KINDS
}
unsupported: list[str] = []
section_skills: list[Any] = []
section_certificates: list[Any] = []
raw_sections = content.get("sections")
if isinstance(raw_sections, Sequence) and not isinstance(raw_sections, (str, bytes)):
for section in raw_sections:
if not isinstance(section, Mapping):
_append_unique(unsupported, "<invalid>")
continue
source_kind = _text(section.get("kind")) or "<missing>"
if source_kind in _METADATA_SECTION_KINDS:
values = _section_values(section)
if source_kind == "skills":
section_skills.extend(values)
else:
section_certificates.extend(values)
continue
target_kind = _SECTION_KIND_MAP.get(source_kind)
if target_kind is None:
_append_unique(unsupported, source_kind)
continue
raw_items = section.get("items")
if not isinstance(raw_items, Sequence) or isinstance(raw_items, (str, bytes)):
continue
for item_index, item in enumerate(raw_items):
if not isinstance(item, Mapping):
continue
section_items[target_kind].append(
_map_section_item(target_kind, item, item_index)
)
skills = _dedupe_text(
[
*_skill_group_values(content.get("skill_groups")),
*_sequence_values(content.get("skills")),
*_sequence_values(tags.get("skills")),
*section_skills,
]
)
certificates = _dedupe_text(
[
*_sequence_values(content.get("certificates")),
*_sequence_values(tags.get("certificates")),
*section_certificates,
]
)
target_position = _first_text(
session_profile.get("target_position"), target.get("position")
)
summary = content.get("profile_summary")
if isinstance(summary, Mapping):
summary = summary.get("content")
if _text(session_profile.get("phone_source")) == "offerpai_resume":
# A C-side pull may intentionally clear the resume phone. The account
# phone remains identity data, but must not repopulate that cleared
# resume field on the next push.
mobile_number = _raw_phone(session_profile.get("phone"))
else:
mobile_number = _raw_phone(
session_profile.get("phone"),
session_profile.get("account_phone"),
external_account.get("mobile_number"),
external_account.get("mobileNumber"),
)
main: dict[str, Any] = {
"resumeName": _first_text(
resume_name,
content.get("resume_name"),
session_profile.get("resume_name"),
),
"targetPosition": target_position,
"avatarUrl": _first_text(
basics.get("avatar_url"),
basics.get("avatarUrl"),
session_profile.get("avatar_url"),
),
"name": _first_text(basics.get("name"), session_profile.get("name")),
"email": _first_text(basics.get("email"), session_profile.get("email")),
"mobileNumber": mobile_number,
"city": _first_text(basics.get("city"), session_profile.get("city")),
"wechatNumber": _first_text(
basics.get("wechat_number"),
basics.get("wechatNumber"),
session_profile.get("wechat_number"),
),
"portfolioUrl": _first_text(
basics.get("portfolio_url"),
basics.get("portfolioUrl"),
session_profile.get("portfolio_url"),
),
"skills": skills,
"certificates": certificates,
"summary": _first_text(summary, content.get("summary"), session_profile.get("summary")),
}
normalized_resume_id: str | None = None
if resume_id is not None:
normalized_resume_id = _normalize_id(resume_id)
main["resumeId"] = normalized_resume_id
section_payloads: dict[SectionKind, dict[str, Any]] = {}
for kind in SUPPORTED_SECTION_KINDS:
payload: dict[str, Any] = {"items": section_items[kind]}
if normalized_resume_id is not None:
payload["resumeId"] = normalized_resume_id
section_payloads[kind] = payload
return OfferPaiResumePayloads(
main=main,
sections=section_payloads,
unsupported_section_kinds=tuple(unsupported),
)
def offerpai_payload_hash(
main: Mapping[str, Any],
sections: Mapping[SectionKind, Mapping[str, Any] | Sequence[Mapping[str, Any]]],
) -> str:
"""Return a semantic hash shared by local projections and remote snapshots.
Service-generated row IDs and paragraph IDs are deliberately excluded: the
C-side replace-all endpoints may regenerate them even when user-visible
resume content is unchanged.
"""
comparable = {
"main": {
"resumeName": _text(main.get("resumeName")),
"targetPosition": _text(main.get("targetPosition")),
"avatarUrl": _text(main.get("avatarUrl")),
"name": _text(main.get("name")),
"email": _text(main.get("email")),
"mobileNumber": _text(main.get("mobileNumber")),
"city": _text(main.get("city")),
"wechatNumber": _text(main.get("wechatNumber")),
"portfolioUrl": _text(main.get("portfolioUrl")),
# Skill grouping in schema v3 can reorder otherwise equivalent
# values by display category, so list order is not sync material.
"skills": sorted(
_dedupe_text(_sequence_values(main.get("skills"))),
key=str.casefold,
),
"certificates": sorted(
_dedupe_text(_sequence_values(main.get("certificates"))),
key=str.casefold,
),
"summary": _text(main.get("summary")),
},
"sections": {
kind: _canonical_section_items(kind, sections.get(kind))
for kind in SUPPORTED_SECTION_KINDS
},
}
return hashlib.sha256(
json.dumps(
comparable,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
def offerpai_update_marker(item: Mapping[str, Any]) -> str | None:
"""Return a stable list-item marker when OfferPai exposes updateTime."""
update_time = item.get("updateTime")
if isinstance(update_time, Mapping):
seconds = update_time.get("seconds")
nanos = update_time.get("nanos")
if seconds is not None or nanos is not None:
return f"{seconds or 0}:{nanos or 0}"
normalized = _text(update_time)
return normalized or None
def merge_offerpai_resume_snapshot(
existing_content: Mapping[str, Any],
session_profile: Mapping[str, Any],
main: Mapping[str, Any],
sections: Mapping[SectionKind, Sequence[Mapping[str, Any]]],
) -> tuple[dict[str, Any], dict[str, Any], tuple[str, ...]]:
"""Merge one C-side snapshot into schema-v3 content and session profile.
OfferPai-supported fields are authoritative. Agent-only sections remain
local, stable entry IDs are retained where possible, and stale AI proposals
are discarded when their underlying entry changed externally.
"""
old = normalize_document(dict(existing_content))
result = deepcopy(old)
profile = deepcopy(dict(session_profile))
basics = deepcopy(old.get("basics") or {})
for remote_key, local_key in (
("name", "name"),
("email", "email"),
("city", "city"),
("avatarUrl", "avatar_url"),
("wechatNumber", "wechat_number"),
("portfolioUrl", "portfolio_url"),
):
value = _text(main.get(remote_key))
if value:
basics[local_key] = value
profile[local_key] = value
else:
basics.pop(local_key, None)
profile.pop(local_key, None)
remote_phone = _raw_phone(main.get("mobileNumber"))
if remote_phone:
basics["masked_phone"] = mask_phone(remote_phone)
basics["phone_source"] = "offerpai_resume"
profile["phone"] = remote_phone
profile["phone_source"] = "offerpai_resume"
else:
basics.pop("masked_phone", None)
basics.pop("phone_source", None)
profile["phone"] = ""
profile["phone_source"] = "offerpai_resume"
result["basics"] = basics
target = deepcopy(old.get("target") or {})
target_position = _text(main.get("targetPosition"))
if target_position:
target["position"] = target_position
profile["target_position"] = target_position
else:
target.pop("position", None)
profile.pop("target_position", None)
result["target"] = target
resume_name = _text(main.get("resumeName"))
if resume_name:
result["resume_name"] = resume_name
profile["resume_name"] = resume_name
else:
result.pop("resume_name", None)
profile.pop("resume_name", None)
skills = _dedupe_text(_sequence_values(main.get("skills")))
certificates = _dedupe_text(_sequence_values(main.get("certificates")))
# These legacy root fields also feed the outbound mapper. Remove stale
# copies so an authoritative C-side clear cannot be undone later.
result.pop("skills", None)
result.pop("certificates", None)
result["skill_groups"] = classify_skills(skills)
tags = deepcopy(profile.get("tags") or {})
tags["skills"] = skills
tags["certificates"] = certificates
profile["tags"] = tags
summary_text = _text(main.get("summary"))
result.pop("summary", None)
previous_summary = old.get("profile_summary")
if summary_text:
summary: dict[str, Any] = {
"content": summary_text,
"source": "offerpai_external",
"generated_at": None,
"stale": False,
}
if (
isinstance(previous_summary, Mapping)
and _text(previous_summary.get("content")) == summary_text
and isinstance(previous_summary.get("pending_proposal"), Mapping)
):
summary["pending_proposal"] = deepcopy(
previous_summary["pending_proposal"]
)
result["profile_summary"] = summary
profile["summary"] = summary_text
else:
result.pop("profile_summary", None)
profile.pop("summary", None)
old_sections = [
section
for section in old.get("sections") or []
if isinstance(section, Mapping)
]
old_by_local_kind: dict[str, Mapping[str, Any]] = {}
unsupported_sections: list[dict[str, Any]] = []
unsupported_kinds: list[str] = []
for section in old_sections:
source_kind = _text(section.get("kind"))
remote_kind = _SECTION_KIND_MAP.get(source_kind)
if remote_kind is not None:
old_by_local_kind[_LOCAL_SECTION_KIND[remote_kind]] = section
elif source_kind == "certificates":
old_by_local_kind["certificates"] = section
else:
unsupported_sections.append(deepcopy(dict(section)))
if source_kind:
_append_unique(unsupported_kinds, source_kind)
synchronized_sections: list[dict[str, Any]] = []
records = deepcopy(profile.get("records") or {})
merged_items_by_kind: dict[str, list[dict[str, Any]]] = {}
for remote_kind in SUPPORTED_SECTION_KINDS:
local_kind = _LOCAL_SECTION_KIND[remote_kind]
old_section = old_by_local_kind.get(local_kind) or {}
remote_items = sections.get(remote_kind) or []
merged_items = _merge_remote_section_items(
remote_kind,
local_kind,
old_section.get("items") or [],
remote_items,
)
merged_items_by_kind[local_kind] = merged_items
records[local_kind] = [
{
**_record_profile_value(item),
"rewrite_confirmed": True,
}
for item in merged_items
]
if merged_items:
synchronized_sections.append(
{
"id": _text(old_section.get("id")) or _new_local_id("sec"),
"kind": local_kind,
"heading": _SECTION_HEADINGS[local_kind],
"items": merged_items,
}
)
old_certificates = old_by_local_kind.get("certificates") or {}
certificate_items = _merge_certificate_items(
old_certificates.get("items") or [], certificates
)
if certificate_items:
synchronized_sections.append(
{
"id": _text(old_certificates.get("id"))
or _new_local_id("sec"),
"kind": "certificates",
"heading": _SECTION_HEADINGS["certificates"],
"items": certificate_items,
}
)
result["sections"] = [*synchronized_sections, *unsupported_sections]
profile["records"] = records
anchor_type = _text(profile.get("anchor_type"))
anchor_remote_kind = _SECTION_KIND_MAP.get(anchor_type)
if anchor_remote_kind is not None:
anchor_items = merged_items_by_kind.get(
_LOCAL_SECTION_KIND[anchor_remote_kind], []
)
profile["anchor"] = (
_record_profile_value(anchor_items[0]) if anchor_items else {}
)
return normalize_document(result), profile, tuple(unsupported_kinds)
def _canonical_section_items(
kind: SectionKind,
source: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None,
) -> list[dict[str, Any]]:
if isinstance(source, Mapping):
raw_items = source.get("items")
else:
raw_items = source
if not isinstance(raw_items, Sequence) or isinstance(raw_items, (str, bytes)):
return []
return [
_canonical_section_item(kind, item)
for item in raw_items
if isinstance(item, Mapping)
]
def _canonical_section_item(
kind: SectionKind, item: Mapping[str, Any]
) -> dict[str, Any]:
description = [
_text(paragraph.get("text"))
for paragraph in item.get("description") or []
if isinstance(paragraph, Mapping) and _text(paragraph.get("text"))
]
if kind == "education":
return {
"school": _text(item.get("school")),
"major": _text(item.get("major")),
"degree": _text(item.get("degree")),
"studyType": _text(item.get("studyType")),
"startDate": _text(item.get("startDate")),
"endDate": _text(item.get("endDate")),
"description": description,
}
if kind in {"work", "internship"}:
return {
"companyName": _text(item.get("companyName")),
"position": _text(item.get("position")),
"startDate": _text(item.get("startDate")),
"endDate": _text(item.get("endDate")),
"description": description,
}
if kind == "project":
return {
"companyName": _text(item.get("companyName")),
"projectName": _text(item.get("projectName")),
"role": _text(item.get("role")),
"startDate": _text(item.get("startDate")),
"endDate": _text(item.get("endDate")),
"description": description,
}
return {
"competitionName": _text(item.get("competitionName")),
"award": _text(item.get("award")),
"awardDate": _text(item.get("awardDate")),
"description": description,
}
def _normalize_remote_record(source: Mapping[str, Any]) -> dict[str, Any]:
item = deepcopy(dict(source))
for key in ("id", "resumeId"):
if item.get(key) is not None:
item[key] = _normalize_id(item[key])
description = item.get("description")
if isinstance(description, Sequence) and not isinstance(
description, (str, bytes)
):
normalized_description: list[dict[str, Any]] = []
for paragraph in description:
if not isinstance(paragraph, Mapping):
continue
value = deepcopy(dict(paragraph))
if value.get("id") is not None:
value["id"] = str(value["id"])
normalized_description.append(value)
item["description"] = normalized_description
return item
def _merge_remote_section_items(
remote_kind: SectionKind,
local_kind: str,
old_items: Sequence[Any],
remote_items: Sequence[Mapping[str, Any]],
) -> list[dict[str, Any]]:
old = [item for item in old_items if isinstance(item, Mapping)]
used: set[int] = set()
merged: list[dict[str, Any]] = []
for index, remote_item in enumerate(remote_items):
candidate = _remote_item_to_local(remote_kind, remote_item)
match_index = _matching_old_item_index(
remote_kind,
local_kind,
old,
used,
candidate,
index,
)
if match_index is None:
candidate["id"] = _new_local_id("entry")
else:
used.add(match_index)
previous = old[match_index]
changed = _entry_sync_projection(
remote_kind, previous, match_index
) != _entry_sync_projection(remote_kind, candidate, index)
candidate["id"] = _text(previous.get("id")) or _new_local_id(
"entry"
)
if changed:
candidate["provenance"] = "external_synced"
if isinstance(previous.get("gap_report"), Mapping):
candidate["gap_report"] = deepcopy(previous["gap_report"])
else:
candidate["provenance"] = (
_text(previous.get("provenance")) or "user_provided"
)
for meta_key in (
"pending_proposal",
"previous_version",
"gap_report",
):
if meta_key in previous:
candidate[meta_key] = deepcopy(previous[meta_key])
merged.append(candidate)
return merged
def _matching_old_item_index(
remote_kind: SectionKind,
local_kind: str,
old_items: Sequence[Mapping[str, Any]],
used: set[int],
candidate: Mapping[str, Any],
candidate_index: int,
) -> int | None:
remote_paragraph_ids = {
value
for value in candidate.get("offerpai_description_ids") or []
if _text(value)
}
if remote_paragraph_ids:
for index, item in enumerate(old_items):
if index in used:
continue
if remote_paragraph_ids.intersection(
_expected_description_ids(remote_kind, item, index)
):
return index
remote_record_id = _text(candidate.get("offerpai_record_id"))
if remote_record_id:
for index, item in enumerate(old_items):
if (
index not in used
and _text(item.get("offerpai_record_id")) == remote_record_id
):
return index
candidate_key = _local_item_key(local_kind, candidate)
if candidate_key is not None:
for index, item in enumerate(old_items):
if (
index not in used
and _local_item_key(local_kind, item) == candidate_key
):
return index
if candidate_index < len(old_items) and candidate_index not in used:
return candidate_index
return None
def _remote_item_to_local(
section: SectionKind, item: Mapping[str, Any]
) -> dict[str, Any]:
paragraphs = [
paragraph
for paragraph in item.get("description") or []
if isinstance(paragraph, Mapping) and _text(paragraph.get("text"))
]
description = "\n".join(_text(paragraph.get("text")) for paragraph in paragraphs)
result: dict[str, Any] = {
"provenance": "external_synced",
}
record_id = _text(item.get("id"))
if record_id:
result["offerpai_record_id"] = record_id
paragraph_ids = [
_text(paragraph.get("id"))
for paragraph in paragraphs
if _text(paragraph.get("id"))
]
if paragraph_ids:
result["offerpai_description_ids"] = paragraph_ids
if description:
result["description"] = description
if section == "education":
result.update(
{
"school": _text(item.get("school")),
"major": _text(item.get("major")),
"degree": _text(item.get("degree")),
"study_type": _text(item.get("studyType")),
"start_date": _parse_remote_month(item.get("startDate")),
"end_date_or_present": _parse_remote_end_month(
item.get("endDate")
),
}
)
elif section in {"work", "internship"}:
result.update(
{
"company": _text(item.get("companyName")),
"position": _text(item.get("position")),
"start_date": _parse_remote_month(item.get("startDate")),
"end_date_or_present": _parse_remote_end_month(
item.get("endDate")
),
}
)
elif section == "project":
result.update(
{
"company": _text(item.get("companyName")),
"project_name": _text(item.get("projectName")),
"project_role": _text(item.get("role")),
"start_date": _parse_remote_month(item.get("startDate")),
"end_date_or_present": _parse_remote_end_month(
item.get("endDate")
),
}
)
else:
result.update(
{
"name": _text(item.get("competitionName")),
"award": _text(item.get("award")),
"date": _parse_remote_month(item.get("awardDate")),
}
)
return {
key: value
for key, value in result.items()
if value is not None and value != ""
}
def _entry_sync_projection(
remote_kind: SectionKind, item: Mapping[str, Any], item_index: int
) -> dict[str, Any]:
return _canonical_section_item(
remote_kind, _map_section_item(remote_kind, item, item_index)
)
def _expected_description_ids(
remote_kind: SectionKind, item: Mapping[str, Any], item_index: int
) -> set[str]:
stored = item.get("offerpai_description_ids")
if isinstance(stored, Sequence) and not isinstance(stored, (str, bytes)):
values = {_text(value) for value in stored if _text(value)}
if values:
return values
return {
_text(paragraph.get("id"))
for paragraph in _description_paragraphs(remote_kind, item, item_index)
if _text(paragraph.get("id"))
}
def _local_item_key(
local_kind: str, item: Mapping[str, Any]
) -> tuple[str, ...] | None:
fields = _LOCAL_ITEM_KEY_FIELDS.get(local_kind) or ()
values = tuple(_text(item.get(field)) for field in fields)
return values if any(values) else None
def _merge_certificate_items(
old_items: Sequence[Any], certificates: Sequence[str]
) -> list[dict[str, Any]]:
old_by_value = {
_text(item.get("value")).casefold(): item
for item in old_items
if isinstance(item, Mapping) and _text(item.get("value"))
}
return [
{
"id": _text((old_by_value.get(value.casefold()) or {}).get("id"))
or _new_local_id("entry"),
"value": value,
"provenance": _text(
(old_by_value.get(value.casefold()) or {}).get("provenance")
)
or "external_synced",
}
for value in certificates
]
def _record_profile_value(item: Mapping[str, Any]) -> dict[str, Any]:
return {
key: deepcopy(value)
for key, value in item.items()
if key not in _EXTERNAL_ENTRY_META
}
def _parse_remote_month(value: Any) -> str:
normalized = _text(value)
if re.fullmatch(r"\d{4}\.\d{2}", normalized):
return normalized.replace(".", "-")
return normalized
def _parse_remote_end_month(value: Any) -> str:
normalized = _parse_remote_month(value)
return normalized or "present"
def _new_local_id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex[:12]}"
def _map_section_item(
section: SectionKind, item: Mapping[str, Any], item_index: int
) -> dict[str, Any]:
dates = {
"startDate": _format_month(
_first_text(item.get("start_date"), item.get("startDate"))
),
"endDate": _format_month(
_first_text(
item.get("end_date_or_present"),
item.get("end_date"),
item.get("endDate"),
)
),
}
description = _description_paragraphs(section, item, item_index)
if section == "education":
return {
"school": _first_text(item.get("school"), item.get("organization")),
"major": _text(item.get("major")),
"degree": _text(item.get("degree")),
"studyType": _first_text(item.get("study_type"), item.get("studyType")),
**dates,
"description": description,
}
if section in {"work", "internship"}:
return {
"companyName": _first_text(
item.get("company"), item.get("company_name"), item.get("organization")
),
"position": _first_text(item.get("position"), item.get("role")),
**dates,
"description": description,
}
if section == "project":
return {
"companyName": _first_text(
item.get("company"), item.get("company_name"), item.get("organization")
),
"projectName": _first_text(
item.get("project_name"), item.get("projectName"), item.get("name"), item.get("title")
),
"role": _first_text(
item.get("project_role"), item.get("projectRole"), item.get("role"), item.get("position")
),
**dates,
"description": description,
}
return {
"competitionName": _first_text(
item.get("competition_name"), item.get("competitionName"), item.get("name"), item.get("title")
),
"award": _text(item.get("award")),
"awardDate": _format_month(
_first_text(item.get("award_date"), item.get("awardDate"), item.get("date"))
),
"description": description,
}
def _description_paragraphs(
section: SectionKind, item: Mapping[str, Any], item_index: int
) -> list[dict[str, str]]:
source: Any = None
for field in ("resume_bullets", "description", "highlights"):
candidate = item.get(field)
if _has_description_value(candidate):
source = candidate
break
raw_paragraphs: list[Any]
if isinstance(source, str):
raw_paragraphs = [line for line in source.splitlines() if line.strip()]
elif isinstance(source, Sequence) and not isinstance(source, (str, bytes)):
raw_paragraphs = list(source)
elif source is None:
raw_paragraphs = []
else:
raw_paragraphs = [source]
stored_ids = item.get("offerpai_description_ids")
reusable_ids = (
[str(value or "").strip() for value in stored_ids]
if isinstance(stored_ids, Sequence)
and not isinstance(stored_ids, (str, bytes))
else []
)
entry_id = _text(item.get("id"))
if entry_id:
entry_seed = entry_id
else:
identity = {
key: value
for key, value in sorted(item.items())
if key
not in {
"description",
"resume_bullets",
"highlights",
"metrics",
"pending_proposal",
"optimized_description",
"provenance",
"offerpai_record_id",
"offerpai_description_ids",
}
}
entry_seed = f"{item_index}:{json.dumps(identity, ensure_ascii=False, sort_keys=True, default=str)}"
paragraphs: list[dict[str, str]] = []
for paragraph_index, raw in enumerate(raw_paragraphs):
source_id = ""
if isinstance(raw, Mapping):
text = _text(raw.get("text"))
source_id = _text(raw.get("id"))
else:
text = _text(raw)
if not text:
continue
if not source_id and paragraph_index < len(reusable_ids):
source_id = reusable_ids[paragraph_index]
if not source_id:
digest = hashlib.sha1(
f"{section}|{entry_seed}|{paragraph_index}".encode("utf-8")
).hexdigest()[:12]
source_id = f"desc_{digest}"
paragraphs.append({"id": source_id, "text": text})
return paragraphs
def _unwrap_response(payload: Any) -> Any:
if isinstance(payload, dict) and "code" in payload:
upstream_code = str(payload.get("code"))
if upstream_code != "0":
raise OfferPaiResumeError(
"offerpai_resume_rejected",
"OfferPai rejected the resume request.",
status_code=400,
upstream_code=upstream_code,
)
return payload.get("data")
return payload
def _validate_token(token: str) -> str:
normalized = str(token or "").strip()
if not _TOKEN_PATTERN.fullmatch(normalized):
raise OfferPaiResumeError(
"external_auth_invalid",
"OfferPai login credential is invalid or expired.",
status_code=401,
)
return normalized
def _invalid_response(message: str) -> OfferPaiResumeError:
return OfferPaiResumeError(
"offerpai_resume_invalid_response",
message,
status_code=502,
)
def _response_id(data: Any, label: str) -> str:
if isinstance(data, Mapping):
data = data.get("id") if data.get("id") is not None else data.get("resumeId")
try:
return _normalize_id(data)
except ValueError as exc:
raise _invalid_response(f"OfferPai returned an invalid {label} ID.") from exc
def _normalize_id(value: Any) -> str:
if isinstance(value, bool):
raise ValueError("ID cannot be boolean")
normalized = str(value if value is not None else "").strip()
if not normalized:
raise ValueError("ID cannot be blank")
return normalized
def _wire_id(value: Any) -> int | str:
normalized = _normalize_id(value)
if re.fullmatch(r"-?\d+", normalized):
return int(normalized)
return normalized
def _mapping(value: Any) -> Mapping[str, Any]:
return value if isinstance(value, Mapping) else {}
def _text(value: Any) -> str:
if value is None or isinstance(value, (dict, list, tuple, set)):
return ""
return str(value).strip()
def _first_text(*values: Any) -> str:
for value in values:
normalized = _text(value)
if normalized:
return normalized
return ""
def _raw_phone(*values: Any) -> str:
for value in values:
normalized = _text(value)
if _RAW_PHONE_PATTERN.fullmatch(normalized):
return normalized
return ""
def _format_month(value: Any) -> str:
normalized = _text(value)
if normalized.casefold() in _PRESENT_VALUES:
return ""
match = _MONTH_PATTERN.fullmatch(normalized)
if match:
return f"{match.group(1)}.{match.group(2)}"
return normalized
def _nonempty_sequence(value: Any) -> bool:
return (
isinstance(value, Sequence)
and not isinstance(value, (str, bytes))
and len(value) > 0
)
def _has_description_value(value: Any) -> bool:
if isinstance(value, Mapping):
return bool(_text(value.get("text")))
return _nonempty_sequence(value) or bool(_text(value))
def _sequence_values(value: Any) -> list[Any]:
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
return list(value)
return []
def _skill_group_values(value: Any) -> list[Any]:
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
return []
skills: list[Any] = []
for group in value:
if isinstance(group, Mapping):
skills.extend(_sequence_values(group.get("skills")))
return skills
def _section_values(section: Mapping[str, Any]) -> list[Any]:
values: list[Any] = []
raw_items = section.get("items")
if not isinstance(raw_items, Sequence) or isinstance(raw_items, (str, bytes)):
return values
for item in raw_items:
if isinstance(item, Mapping):
values.append(
_first_text(item.get("value"), item.get("skill"), item.get("name"))
)
else:
values.append(item)
return values
def _dedupe_text(values: Sequence[Any]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values:
normalized = _text(value)
key = normalized.casefold()
if not normalized or key in seen:
continue
seen.add(key)
result.append(normalized)
return result
def _append_unique(values: list[str], value: str) -> None:
if value not in values:
values.append(value)