Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/src/five08/backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class ResumeApplyRequest(BaseModel):
"""Request schema for queued resume apply updates."""

contact_id: str
updates: dict[str, str]
updates: dict[str, Any]
link_discord: dict[str, str] | None = None


Expand Down
16 changes: 10 additions & 6 deletions apps/discord_bot/src/five08/discord_bot/cogs/crm.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ def __init__(
requester_id: int,
contact_id: str,
contact_name: str,
proposed_updates: dict[str, str],
proposed_updates: dict[str, Any],
link_discord: dict[str, str] | None = None,
) -> None:
super().__init__(timeout=300)
Expand Down Expand Up @@ -1116,7 +1116,7 @@ async def _enqueue_resume_apply_job(
self,
*,
contact_id: str,
updates: dict[str, str],
updates: dict[str, Any],
link_discord: dict[str, str] | None = None,
) -> str:
payload = {
Expand Down Expand Up @@ -1176,15 +1176,19 @@ def _build_resume_preview_embed(
contact_name: str,
result: dict[str, Any],
link_member: discord.Member | None,
) -> tuple[discord.Embed, dict[str, str]]:
) -> tuple[discord.Embed, dict[str, Any]]:
"""Render backend extraction result as a Discord preview embed."""
proposed_updates_raw = result.get("proposed_updates")
proposed_updates: dict[str, str] = {}
proposed_updates: dict[str, Any] = {}
if isinstance(proposed_updates_raw, dict):
proposed_updates = {
str(field): str(value)
str(field): value
for field, value in proposed_updates_raw.items()
if value is not None and str(value).strip()
if value is not None
and not (
isinstance(value, (dict, list, tuple, set)) and len(value) == 0
)
and (not isinstance(value, str) or value.strip())
}

changes = result.get("proposed_changes")
Expand Down
79 changes: 32 additions & 47 deletions apps/worker/src/five08/worker/crm/resume_profile_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ def extract_profile_proposal(
merged_skills=merged_skills,
)

proposed_updates: dict[str, str] = {}
proposed_updates: dict[str, Any] = {}
proposed_changes: list[ResumeFieldChange] = []
skipped: list[ResumeSkipReason] = []

Expand Down Expand Up @@ -318,32 +318,22 @@ def extract_profile_proposal(
skipped=skipped,
)
if new_skills:
proposed_updates["skills"] = ", ".join(merged_skills)
proposed_updates["skills"] = merged_skills
proposed_changes.append(
ResumeFieldChange(
field="skills",
label="Skills",
current=", ".join(existing_skills) if existing_skills else None,
proposed=", ".join(merged_skills),
reason=f"Added {len(new_skills)} skills from resume extraction",
)
)

if merged_skill_attrs and merged_skill_attrs != existing_skill_attrs:
proposed_updates["cSkillAttrs"] = self._serialize_skill_attrs(
merged_skill_attrs
)
proposed_changes.append(
ResumeFieldChange(
field="cSkillAttrs",
label="Skill Attributes",
current=(
f"{len(existing_skill_attrs)} skills rated"
if existing_skill_attrs
self._format_skills_with_strength(
existing_skills, existing_skill_attrs
)
if existing_skills
else None
),
proposed=f"{len(merged_skill_attrs)} skills rated (strength 1-5)",
reason="Updated structured skill strengths from resume extraction",
proposed=self._format_skills_with_strength(
merged_skills, merged_skill_attrs
),
reason="Added skills from resume extraction",
)
)

Expand Down Expand Up @@ -407,7 +397,7 @@ def apply_profile_updates(
self,
*,
contact_id: str,
updates: dict[str, str],
updates: dict[str, Any],
link_discord: dict[str, str] | None = None,
) -> ResumeApplyResult:
"""Apply confirmed updates to contact in CRM."""
Expand All @@ -418,7 +408,6 @@ def apply_profile_updates(
settings.crm_linkedin_field,
"phoneNumber",
"skills",
"cSkillAttrs",
}
sanitized_updates: dict[str, Any] = {
field: value
Expand All @@ -427,19 +416,11 @@ def apply_profile_updates(
}

email_value = sanitized_updates.get("emailAddress")
if email_value and email_value.lower().endswith("@508.dev"):
if isinstance(email_value, str) and email_value.lower().endswith(
"@508.dev"
):
sanitized_updates.pop("emailAddress")

if "cSkillAttrs" in sanitized_updates:
parsed_attrs = self._parse_skill_attrs(sanitized_updates["cSkillAttrs"])
# Be forgiving: if value is malformed, overwrite with an empty object.
if parsed_attrs:
sanitized_updates["cSkillAttrs"] = json.loads(
self._serialize_skill_attrs(parsed_attrs)
)
else:
sanitized_updates["cSkillAttrs"] = {}

if not sanitized_updates:
return ResumeApplyResult(
contact_id=contact_id,
Expand Down Expand Up @@ -614,7 +595,7 @@ def _collect_change(
label: str,
current: Any,
proposed: str | None,
proposed_updates: dict[str, str],
proposed_updates: dict[str, Any],
proposed_changes: list[ResumeFieldChange],
skipped: list[ResumeSkipReason],
blocked_reason: str | None = None,
Expand Down Expand Up @@ -672,6 +653,23 @@ def _parse_existing_skills(self, value: Any) -> list[str]:
normalized.append(canonical)
return normalized

def _format_skills_with_strength(
self,
skills: list[str],
attrs: dict[str, int],
) -> str:
formatted: list[str] = []
for raw_skill in skills:
skill = raw_skill.strip()
if not skill:
continue
strength = attrs.get(skill.casefold())
if strength:
formatted.append(f"{skill} ({strength})")
else:
formatted.append(skill)
return ", ".join(formatted)

def _parse_skill_attrs(self, value: Any) -> dict[str, int]:
if value is None:
return {}
Expand Down Expand Up @@ -716,26 +714,13 @@ def _merge_skill_attrs(
) -> dict[str, int]:
merged: dict[str, int] = dict(existing_attrs)

for skill in merged_skills:
key = str(skill).strip().casefold()
if key and key not in merged:
merged[key] = 3

for skill, attrs in extracted_attrs.items():
key = str(skill).strip().casefold()
if key:
merged[key] = max(1, min(5, int(attrs.strength)))

return merged

def _serialize_skill_attrs(self, attrs: dict[str, int]) -> str:
payload = {
skill: {"strength": max(1, min(5, int(strength)))}
for skill, strength in sorted(attrs.items())
if skill
}
return json.dumps(payload, separators=(",", ":"), sort_keys=True)

def _mark_resume_processed(self, contact_id: str) -> None:
"""Best-effort update for extraction completion tracking."""
processed_at = datetime.now(tz=timezone.utc).isoformat()
Expand Down
62 changes: 45 additions & 17 deletions apps/worker/src/five08/worker/crm/skills_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ def extract_skills(self, resume_text: str) -> ExtractedSkills:
"engineering, product, data, design, growth, and marketing. "
"Return JSON only, no prose. "
"Normalize skills to concise canonical names, lowercase. "
"Provide a strength from 1-5 for each skill, where 5 is strongest."
"Provide a strength from 1-5 when known, where 5 is strongest. "
"If uncertain, you may omit it or leave it blank. "
"Bias 3 for simple mentions, 4-5 for recent/current project usage, "
"and 1-2 for weak, outdated, or minimal exposure."
),
},
{"role": "user", "content": prompt},
Expand Down Expand Up @@ -137,14 +140,17 @@ def _create_prompt(self, resume_text: str) -> str:
"data, design, growth, and marketing.\n"
"Exclude personal traits and vague soft skills unless role-critical.\n"
"Return JSON with this exact schema:\n"
'{"skills": ["skill1", "skill2"], '
'"skill_attrs": {"skill1": {"strength": 4}}, '
'{"skills": ["skill1", "skill2", "skill3 (4)"], '
'"confidence": 0.8}\n'
"Rules:\n"
"- skills must be lowercase canonical names with minimal punctuation\n"
'- prefer forms like "nodejs", "ab testing", "go to market"\n'
"- skill_attrs keys must match skills\n"
"- strength is integer 1-5 (5 strongest)\n"
"- optional strength may be included inline for a skill in parentheses, e.g. skill (4)\n"
"- if strength is uncertain, omit the suffix or use an empty suffix, e.g. skill ()\n"
"- strength is integer 1-5 (5 strongest), and should be assigned per above.\n"
"- use 3 when a skill is simply mentioned without strong context\n"
"- use 4 or 5 when usage is clearly current or recent in project work\n"
"- use 2 for older, side, or weak mentions and 1 for very weak/outdated evidence\n"
"- no extra keys\n\n"
f"Resume:\n{snippet}"
)
Expand All @@ -168,28 +174,27 @@ def _normalize_extracted_payload(
confidence: float,
source: str,
) -> ExtractedSkills:
attrs_map: dict[str, SkillAttributes] = {}
raw_skills = skills_value if isinstance(skills_value, list) else []
normalized_skills: list[str] = []
for skill in raw_skills:
canonical = self._normalize_skill_name(str(skill))
canonical, inline_strength = self._parse_skill_with_strength(str(skill))
if canonical:
normalized_skills.append(canonical)
if inline_strength is not None:
attrs_map[canonical] = SkillAttributes(strength=inline_strength)

attrs_map: dict[str, SkillAttributes] = {}
if isinstance(skill_attrs_value, dict):
for raw_name, raw_attr in skill_attrs_value.items():
canonical = self._normalize_skill_name(str(raw_name))
if not canonical:
continue
attrs_map[canonical] = SkillAttributes(
strength=self._parse_strength(raw_attr)
)
strength = self._parse_strength(raw_attr)
if strength is not None:
attrs_map[canonical] = SkillAttributes(strength=strength)

# Ensure attrs exists for every skill and include attr-only entries in skill list.
# Include attr-only entries in the skill list.
deduped_skills = sorted(set(normalized_skills) | set(attrs_map.keys()))
for skill in deduped_skills:
if skill not in attrs_map:
attrs_map[skill] = SkillAttributes(strength=DEFAULT_SKILL_STRENGTH)

return ExtractedSkills(
skills=deduped_skills,
Expand All @@ -198,15 +203,38 @@ def _normalize_extracted_payload(
source=source,
)

def _parse_strength(self, value: Any) -> int:
def _parse_strength(self, value: Any) -> int | None:
raw: Any = value
if isinstance(value, dict):
raw = value.get("strength")
if raw is None:
return None
if isinstance(raw, str) and not raw.strip():
return None
try:
numeric = int(float(raw))
except Exception:
numeric = DEFAULT_SKILL_STRENGTH
return max(1, min(5, numeric))
return None
if numeric < 1 or numeric > 5:
return None
return numeric

def _parse_skill_with_strength(self, value: str) -> tuple[str, int | None]:
raw = value.strip()
match = re.match(r"^(.*)\(\s*(\d*)\s*\)\s*$", raw)
if match is None:
return self._normalize_skill_name(raw), None

base = match.group(1).strip()
parsed_strength = self._parse_strength(match.group(2))
if not base:
return "", None
normalized_base = self._normalize_skill_name(base)
if not normalized_base:
return "", None
if parsed_strength is None:
return normalized_base, None
return normalized_base, parsed_strength

def _normalize_skill_name(self, value: str) -> str:
return normalize_skill(value)
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/src/five08/worker/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def extract_resume_profile_job(

def apply_resume_profile_job(
contact_id: str,
updates: dict[str, str],
updates: dict[str, Any],
link_discord: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Apply confirmed CRM profile updates after bot-side confirmation."""
Expand Down
6 changes: 4 additions & 2 deletions apps/worker/src/five08/worker/mailbox_resume_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,9 +545,11 @@ def _process_attachment(
return False

proposed_updates = {
str(field): str(value)
str(field): value
for field, value in candidate_extract.proposed_updates.items()
if value is not None and str(value).strip()
if value is not None
and not (isinstance(value, (dict, list, tuple, set)) and len(value) == 0)
and (not isinstance(value, str) or value.strip())
}
if not proposed_updates:
return True
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/src/five08/worker/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ class ResumeExtractionResult(BaseModel):

contact_id: str
attachment_id: str
proposed_updates: dict[str, str]
proposed_updates: dict[str, Any]
proposed_changes: list[ResumeFieldChange]
skipped: list[ResumeSkipReason]
extracted_profile: ResumeExtractedProfile
Expand Down
16 changes: 14 additions & 2 deletions tests/unit/test_crm.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,8 +595,20 @@ async def test_link_discord_user_name_search(
call_args = crm_cog.espo_api.request.call_args
search_params = call_args[0][2] # Third argument is the search params
# Check that it searched for "john" as a name
assert search_params["where"][0]["attribute"] == "name"
assert search_params["where"][0]["value"] == "john"
first_where = search_params["where"][0]
if first_where.get("type") == "or":
where_filters = first_where.get("value", [])
assert isinstance(where_filters, list)
where_filter = next(
(item for item in where_filters if item.get("attribute") == "name"),
None,
)
assert where_filter is not None
assert where_filter["value"] == "john"
return

assert first_where["attribute"] == "name"
assert first_where["value"] == "john"

@pytest.mark.asyncio
async def test_link_discord_user_modern_username(
Expand Down
Loading