generated from kgod/ai-review-template
1357 lines
45 KiB
Python
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)
|