From 9b1cbfa28d077357bd0464dbdf0e0fba348d4100 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 3 Mar 2026 01:53:20 +0800 Subject: [PATCH 1/5] Fix resume skills updates to array payload --- apps/api/src/five08/backend/api.py | 2 +- .../src/five08/discord_bot/cogs/crm.py | 16 ++-- .../worker/crm/resume_profile_processor.py | 82 ++++++++----------- .../src/five08/worker/crm/skills_extractor.py | 60 ++++++++++---- apps/worker/src/five08/worker/jobs.py | 2 +- .../five08/worker/mailbox_resume_ingest.py | 6 +- apps/worker/src/five08/worker/models.py | 2 +- tests/unit/test_resume_profile_processor.py | 26 ++---- tests/unit/test_skills_extractor.py | 16 ++++ 9 files changed, 119 insertions(+), 93 deletions(-) diff --git a/apps/api/src/five08/backend/api.py b/apps/api/src/five08/backend/api.py index 429f0e84..4dd67113 100644 --- a/apps/api/src/five08/backend/api.py +++ b/apps/api/src/five08/backend/api.py @@ -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 diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 1eee2d1f..4bf4d720 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -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) @@ -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 = { @@ -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") diff --git a/apps/worker/src/five08/worker/crm/resume_profile_processor.py b/apps/worker/src/five08/worker/crm/resume_profile_processor.py index bd61536c..fb4cee99 100644 --- a/apps/worker/src/five08/worker/crm/resume_profile_processor.py +++ b/apps/worker/src/five08/worker/crm/resume_profile_processor.py @@ -274,8 +274,9 @@ def extract_profile_proposal( extracted_attrs=extracted_skills_result.skill_attrs, merged_skills=merged_skills, ) + skills_attrs_updated = merged_skill_attrs != existing_skill_attrs - proposed_updates: dict[str, str] = {} + proposed_updates: dict[str, Any] = {} proposed_changes: list[ResumeFieldChange] = [] skipped: list[ResumeSkipReason] = [] @@ -317,33 +318,23 @@ def extract_profile_proposal( proposed_changes=proposed_changes, skipped=skipped, ) - if new_skills: - proposed_updates["skills"] = ", ".join(merged_skills) + if new_skills or skills_attrs_updated: + 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="Updated skills from resume extraction", ) ) @@ -407,7 +398,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.""" @@ -418,7 +409,6 @@ def apply_profile_updates( settings.crm_linkedin_field, "phoneNumber", "skills", - "cSkillAttrs", } sanitized_updates: dict[str, Any] = { field: value @@ -427,19 +417,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, @@ -614,7 +596,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, @@ -672,6 +654,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 {} @@ -716,11 +715,6 @@ 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: @@ -728,14 +722,6 @@ def _merge_skill_attrs( 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() diff --git a/apps/worker/src/five08/worker/crm/skills_extractor.py b/apps/worker/src/five08/worker/crm/skills_extractor.py index 094f7145..b63543c3 100644 --- a/apps/worker/src/five08/worker/crm/skills_extractor.py +++ b/apps/worker/src/five08/worker/crm/skills_extractor.py @@ -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}, @@ -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}" ) @@ -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, @@ -198,16 +203,39 @@ 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 None + if numeric <= 0: + return None return max(1, min(5, numeric)) + def _parse_skill_with_strength(self, value: str) -> tuple[str, int | None]: + raw = value.strip() + match = re.match(r"^(.*)\((\d)\)\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) diff --git a/apps/worker/src/five08/worker/jobs.py b/apps/worker/src/five08/worker/jobs.py index c2dd1530..6637f532 100644 --- a/apps/worker/src/five08/worker/jobs.py +++ b/apps/worker/src/five08/worker/jobs.py @@ -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.""" diff --git a/apps/worker/src/five08/worker/mailbox_resume_ingest.py b/apps/worker/src/five08/worker/mailbox_resume_ingest.py index 2325145d..3d3e3502 100644 --- a/apps/worker/src/five08/worker/mailbox_resume_ingest.py +++ b/apps/worker/src/five08/worker/mailbox_resume_ingest.py @@ -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 diff --git a/apps/worker/src/five08/worker/models.py b/apps/worker/src/five08/worker/models.py index 037dc9ad..9a221df3 100644 --- a/apps/worker/src/five08/worker/models.py +++ b/apps/worker/src/five08/worker/models.py @@ -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 diff --git a/tests/unit/test_resume_profile_processor.py b/tests/unit/test_resume_profile_processor.py index c58fca44..16b07d08 100644 --- a/tests/unit/test_resume_profile_processor.py +++ b/tests/unit/test_resume_profile_processor.py @@ -1,6 +1,5 @@ """Unit tests for resume profile worker processor.""" -import json from unittest.mock import Mock from five08.worker.crm.resume_profile_processor import ResumeProfileProcessor @@ -54,11 +53,7 @@ def test_extract_profile_proposal_filters_508_email() -> None: assert result.proposed_updates["cGitHubUsername"] == "new-gh" assert result.proposed_updates["cLinkedInUrl"] == "https://linkedin.com/in/new" assert result.proposed_updates["phoneNumber"] == "14155551234" - assert result.proposed_updates["skills"] == "Python, FastAPI" - assert "cSkillAttrs" in result.proposed_updates - attrs_payload = json.loads(result.proposed_updates["cSkillAttrs"]) - assert attrs_payload["python"]["strength"] == 5 - assert attrs_payload["fastapi"]["strength"] == 4 + assert result.proposed_updates["skills"] == ["Python", "FastAPI"] assert result.new_skills == ["Python", "FastAPI"] assert any(item.field == "emailAddress" for item in result.skipped) processor.crm.update_contact.assert_called_once() @@ -121,11 +116,11 @@ def test_extract_profile_proposal_normalizes_existing_skill_punctuation() -> Non ) assert result.success is True - assert result.proposed_updates["skills"] == "node, ab testing, product management" - attrs_payload = json.loads(result.proposed_updates["cSkillAttrs"]) - assert attrs_payload["node"]["strength"] == 5 - assert attrs_payload["ab testing"]["strength"] == 3 - assert attrs_payload["product management"]["strength"] == 4 + assert result.proposed_updates["skills"] == [ + "node", + "ab testing", + "product management", + ] def test_apply_profile_updates_adds_discord_and_filters_email() -> None: @@ -139,8 +134,7 @@ def test_apply_profile_updates_adds_discord_and_filters_email() -> None: "emailAddress": "member@508.dev", "cGitHubUsername": "new-gh", "phoneNumber": "14155551234", - "skills": "Python, FastAPI", - "cSkillAttrs": '{"python":{"strength":4},"fastapi":{"strength":3}}', + "skills": ["Python", "FastAPI"], }, link_discord={"user_id": "123", "username": "member#0001"}, ) @@ -151,11 +145,7 @@ def test_apply_profile_updates_adds_discord_and_filters_email() -> None: assert "emailAddress" not in update_payload assert update_payload["cGitHubUsername"] == "new-gh" assert update_payload["phoneNumber"] == "14155551234" - assert update_payload["skills"] == "Python, FastAPI" - assert update_payload["cSkillAttrs"] == { - "fastapi": {"strength": 3}, - "python": {"strength": 4}, - } + assert update_payload["skills"] == ["Python", "FastAPI"] assert update_payload["cDiscordUserID"] == "123" assert update_payload["cDiscordUsername"] == "member#0001 (ID: 123)" diff --git a/tests/unit/test_skills_extractor.py b/tests/unit/test_skills_extractor.py index c1bc75ae..68dc27d7 100644 --- a/tests/unit/test_skills_extractor.py +++ b/tests/unit/test_skills_extractor.py @@ -54,3 +54,19 @@ def test_normalize_extracted_payload_canonicalizes_and_clamps_strength() -> None assert result.skill_attrs["ab testing"].strength == 1 assert result.skill_attrs["node"].strength == 2 assert result.skill_attrs["go to market"].strength == 4 + + +def test_normalize_extracted_payload_parses_inline_strength_suffixes() -> None: + """Inline strengths like `skill (4)` should be parsed when included in the skills list.""" + extractor = SkillsExtractor() + + result = extractor._normalize_extracted_payload( + skills_value=["Python (4)", "code review ()", "TypeScript"], + skill_attrs_value=None, + confidence=0.9, + source="model", + ) + + assert result.skills == ["code review", "python", "typescript"] + assert result.skill_attrs["python"].strength == 4 + assert "code review" not in result.skill_attrs From 94491108dd59b6414b9b1171b39ff4a5e38016d2 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 3 Mar 2026 09:50:23 +0800 Subject: [PATCH 2/5] Improve skills extraction regex and numeric parsing - Update regex to allow optional whitespace inside parentheses - Support zero or more digits in strength field - Remove validation check that rejected zero or negative values Co-Authored-By: Claude Haiku 4.5 --- apps/worker/src/five08/worker/crm/skills_extractor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/worker/src/five08/worker/crm/skills_extractor.py b/apps/worker/src/five08/worker/crm/skills_extractor.py index b63543c3..e0f51170 100644 --- a/apps/worker/src/five08/worker/crm/skills_extractor.py +++ b/apps/worker/src/five08/worker/crm/skills_extractor.py @@ -215,13 +215,11 @@ def _parse_strength(self, value: Any) -> int | None: numeric = int(float(raw)) except Exception: return None - if numeric <= 0: - return None return max(1, min(5, numeric)) def _parse_skill_with_strength(self, value: str) -> tuple[str, int | None]: raw = value.strip() - match = re.match(r"^(.*)\((\d)\)\s*$", raw) + match = re.match(r"^(.*)\(\s*(\d*)\s*\)\s*$", raw) if match is None: return self._normalize_skill_name(raw), None From fbae71275d6c1beb2d4ed301ae5fd7efefc8168f Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 3 Mar 2026 10:16:46 +0800 Subject: [PATCH 3/5] Fix skill strength parsing and proposal persistence consistency --- .../worker/crm/resume_profile_processor.py | 5 +- .../src/five08/worker/crm/skills_extractor.py | 4 +- tests/unit/test_resume_profile_processor.py | 46 +++++++++++++++++++ tests/unit/test_skills_extractor.py | 8 ++-- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/apps/worker/src/five08/worker/crm/resume_profile_processor.py b/apps/worker/src/five08/worker/crm/resume_profile_processor.py index fb4cee99..892cc5d4 100644 --- a/apps/worker/src/five08/worker/crm/resume_profile_processor.py +++ b/apps/worker/src/five08/worker/crm/resume_profile_processor.py @@ -274,7 +274,6 @@ def extract_profile_proposal( extracted_attrs=extracted_skills_result.skill_attrs, merged_skills=merged_skills, ) - skills_attrs_updated = merged_skill_attrs != existing_skill_attrs proposed_updates: dict[str, Any] = {} proposed_changes: list[ResumeFieldChange] = [] @@ -318,7 +317,7 @@ def extract_profile_proposal( proposed_changes=proposed_changes, skipped=skipped, ) - if new_skills or skills_attrs_updated: + if new_skills: proposed_updates["skills"] = merged_skills proposed_changes.append( ResumeFieldChange( @@ -334,7 +333,7 @@ def extract_profile_proposal( proposed=self._format_skills_with_strength( merged_skills, merged_skill_attrs ), - reason="Updated skills from resume extraction", + reason="Added skills from resume extraction", ) ) diff --git a/apps/worker/src/five08/worker/crm/skills_extractor.py b/apps/worker/src/five08/worker/crm/skills_extractor.py index e0f51170..96828f11 100644 --- a/apps/worker/src/five08/worker/crm/skills_extractor.py +++ b/apps/worker/src/five08/worker/crm/skills_extractor.py @@ -215,7 +215,9 @@ def _parse_strength(self, value: Any) -> int | None: numeric = int(float(raw)) except Exception: return None - return max(1, min(5, numeric)) + 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() diff --git a/tests/unit/test_resume_profile_processor.py b/tests/unit/test_resume_profile_processor.py index 16b07d08..c6ab09a8 100644 --- a/tests/unit/test_resume_profile_processor.py +++ b/tests/unit/test_resume_profile_processor.py @@ -123,6 +123,52 @@ def test_extract_profile_proposal_normalizes_existing_skill_punctuation() -> Non ] +def test_extract_profile_proposal_with_strength_change_only_no_skill_proposal() -> None: + """Strength-only changes to existing skills should not be shown as editable updates.""" + processor = ResumeProfileProcessor() + processor.crm = Mock() + processor.extractor = Mock() + processor.skills_extractor = Mock() + processor.document_processor = Mock() + processor._record_processing_run = Mock() + + processor.crm.get_contact.return_value = { + "emailAddress": "member@example.com", + "skills": ["Python"], + "cSkillAttrs": '{"python":{"strength":5}}', + } + processor.crm.download_attachment.return_value = b"resume-bytes" + processor.document_processor.extract_text.return_value = "resume text" + processor.document_processor.get_content_hash.return_value = "hash-20" + processor.extractor.extract.return_value = ResumeExtractedProfile( + email=None, + github_username=None, + linkedin_url=None, + phone=None, + confidence=0.9, + source="gpt-4o-mini", + ) + processor.skills_extractor.extract_skills.return_value = ExtractedSkills( + skills=["python"], + skill_attrs={"python": {"strength": 3}}, + confidence=0.8, + source="gpt-4o-mini", + ) + processor.skills_extractor.canonicalize_skill.side_effect = ( + lambda v: str(v).strip().lower() + ) + + result = processor.extract_profile_proposal( + contact_id="contact-20", + attachment_id="att-20", + filename="resume.pdf", + ) + + assert result.success is True + assert "skills" not in result.proposed_updates + assert not any(item.field == "skills" for item in result.proposed_changes) + + def test_apply_profile_updates_adds_discord_and_filters_email() -> None: """Apply should include Discord link values and prevent @508.dev email writes.""" processor = ResumeProfileProcessor() diff --git a/tests/unit/test_skills_extractor.py b/tests/unit/test_skills_extractor.py index 68dc27d7..8949c74f 100644 --- a/tests/unit/test_skills_extractor.py +++ b/tests/unit/test_skills_extractor.py @@ -25,8 +25,8 @@ def test_heuristic_extractor_includes_two_letter_go_skill() -> None: assert "python" in result.skills -def test_normalize_extracted_payload_canonicalizes_and_clamps_strength() -> None: - """LLM payload normalization should map aliases and clamp strengths to 1-5.""" +def test_normalize_extracted_payload_canonicalizes_and_validates_strength() -> None: + """LLM payload normalization should map aliases and ignore out-of-range strengths.""" extractor = SkillsExtractor() result = extractor._normalize_extracted_payload( @@ -49,9 +49,9 @@ def test_normalize_extracted_payload_canonicalizes_and_clamps_strength() -> None "node", "product management", ] - assert result.skill_attrs["javascript"].strength == 5 + assert "javascript" not in result.skill_attrs assert result.skill_attrs["product management"].strength == 4 - assert result.skill_attrs["ab testing"].strength == 1 + assert "ab testing" not in result.skill_attrs assert result.skill_attrs["node"].strength == 2 assert result.skill_attrs["go to market"].strength == 4 From dce95d3ba3a2b5099070bf152e19f55e59c77324 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 3 Mar 2026 10:18:35 +0800 Subject: [PATCH 4/5] Format test file for CI ruff check --- tests/unit/test_resume_profile_processor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_resume_profile_processor.py b/tests/unit/test_resume_profile_processor.py index c6ab09a8..1cc43c05 100644 --- a/tests/unit/test_resume_profile_processor.py +++ b/tests/unit/test_resume_profile_processor.py @@ -154,8 +154,8 @@ def test_extract_profile_proposal_with_strength_change_only_no_skill_proposal() confidence=0.8, source="gpt-4o-mini", ) - processor.skills_extractor.canonicalize_skill.side_effect = ( - lambda v: str(v).strip().lower() + processor.skills_extractor.canonicalize_skill.side_effect = lambda v: ( + str(v).strip().lower() ) result = processor.extract_profile_proposal( From 20c82efdb64ca1b760f0fc8faef07151e059f5e6 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 3 Mar 2026 10:25:14 +0800 Subject: [PATCH 5/5] Make link name search assertion support or-filter responses --- tests/unit/test_crm.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 89f6cfd3..552bf838 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -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(