Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 4
feat: enrich resume extraction and crm mapping#134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -96,9 +96,9 @@ RESUME_KEYWORDS=resume,cv,curriculum | ||
| OPENAI_API_KEY= | ||
| # For OpenRouter, set OPENAI_BASE_URL=https://openrouter.ai/api/v1 | ||
| OPENAI_BASE_URL= | ||
| OPENAI_MODEL=gpt-4o-mini | ||
| OPENAI_MODEL=5o-mini | ||
| # Resume model name without provider prefix; OpenRouter is auto-prefixed to openai/<model> | ||
| RESUME_AI_MODEL=gpt-4o-mini | ||
| RESUME_AI_MODEL=5o-mini | ||
Comment on lines
+99
to
+101
CopilotAI | ||
| RESUME_EXTRACTOR_VERSION=v1 | ||
| CRM_SYNC_ENABLED=true | ||
| CRM_SYNC_INTERVAL_SECONDS=900 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -3225,6 +3225,10 @@ def _extract_resume_contact_hints(self, file_content: bytes) -> dict[str, Any]: | ||
| "phone": profile.phone, | ||
| "name": profile.name, | ||
| "address_country": profile.address_country, | ||
| "timezone": profile.timezone, | ||
| "address_city": profile.address_city, | ||
| "description": profile.description, | ||
| "primary_roles": profile.primary_roles, | ||
| "seniority_level": profile.seniority_level, | ||
| "skills": profile.skills, | ||
| "availability": profile.availability, | ||
| @@ -3278,6 +3282,50 @@ def _format_inferred_attempts(self, attempts: list[dict[str, Any]] | None) -> st | ||
| return ", ".join(formatted) | ||
| @staticmethod | ||
| def _normalize_timezone(value: Any) -> str | None: | ||
| if not isinstance(value, str): | ||
| return None | ||
| raw = value.strip().replace(" ", "") | ||
| if not raw: | ||
| return None | ||
| utc_pattern = re.search( | ||
| r"(?i)\b(?:utc|gmt)\s*([+-]\d{1,2}(?:[:.]?[0-5]?\d)?)\b", raw | ||
| ) | ||
| if utc_pattern: | ||
| raw = utc_pattern.group(1) | ||
| if raw.lower() in {"utc", "gmt"}: | ||
| return "UTC+00:00" | ||
| if raw[0] not in {"+", "-"}: | ||
| return None | ||
| match = re.match(r"([+-])(\d{1,2})(?::?([0-5]?\d))?$", raw) | ||
| if not match: | ||
Comment on lines
+3294
to
+3305
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix timezone parser to accept dot-separated offsets it already matches. Line [3295] allows offsets like 💡 Proposed fix- match = re.match(r"([+-])(\d{1,2})(?::?([0-5]?\d))?$", raw)+ match = re.match(r"([+-])(\d{1,2})(?:[:.]?([0-5]?\d))?$", raw)🤖 Prompt for AI Agents | ||
| return None | ||
| sign = match.group(1) | ||
| try: | ||
| hours = int(match.group(2)) | ||
| except Exception: | ||
| return None | ||
| if not 0 <= hours <= 14: | ||
| return None | ||
| minutes = match.group(3) | ||
| if minutes is None: | ||
| minutes_value = 0 | ||
| else: | ||
| try: | ||
| minutes_value = int(minutes) | ||
| except Exception: | ||
| return None | ||
| if minutes_value > 59: | ||
| return None | ||
| return f"UTC{sign}{hours:02d}:{minutes_value:02d}" | ||
Comment on lines
+3285
to
+3327
CopilotAI | ||
| def _build_inference_lookup_summary( | ||
| self, *, file_content: bytes, attempts: list[dict[str, Any]] | None | ||
| ) -> str: | ||
| @@ -3379,6 +3427,7 @@ def _build_resume_create_contact_payload( | ||
| github_usernames = hints.get("github_usernames", []) | ||
| linkedin_urls = hints.get("linkedin_urls", []) | ||
| skills = hints.get("skills", []) | ||
| description = str(hints.get("description", "")).strip() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent At Line [3430] and Line [3473], 💡 Proposed fix- description = str(hints.get("description", "")).strip()+ raw_description = hints.get("description")+ description = raw_description.strip() if isinstance(raw_description, str) else ""
@@
- address_city = str(hints.get("address_city", "")).strip()+ raw_address_city = hints.get("address_city")+ address_city = (+ raw_address_city.strip() if isinstance(raw_address_city, str) else ""+ )Also applies to: 3473-3475 🤖 Prompt for AI Agents | ||
| if not isinstance(emails, list): | ||
| emails = [] | ||
| if not isinstance(github_usernames, list): | ||
| @@ -3388,7 +3437,7 @@ def _build_resume_create_contact_payload( | ||
| if not isinstance(skills, list): | ||
| skills = [] | ||
| payload: dict[str, str] = { | ||
| payload: dict[str, Any] = { | ||
| "type": "Prospect", | ||
| "name": contact_name, | ||
| } | ||
| @@ -3406,12 +3455,29 @@ def _build_resume_create_contact_payload( | ||
| phone = hints.get("phone") | ||
| if isinstance(phone, str) and phone.strip(): | ||
| payload["phoneNumber"] = phone.strip() | ||
| primary_roles = hints.get("primary_roles") | ||
| if isinstance(primary_roles, list): | ||
| normalized_roles = [ | ||
| str(role).strip() | ||
| for role in primary_roles | ||
| if isinstance(role, str) and role.strip() | ||
| ] | ||
| if normalized_roles: | ||
| payload["cRoles"] = normalized_roles | ||
| address_country = str(hints.get("address_country", "")).strip() | ||
| if address_country: | ||
| payload["addressCountry"] = address_country | ||
| timezone = self._normalize_timezone(hints.get("timezone")) | ||
| if timezone: | ||
| payload["cTimezone"] = timezone | ||
| address_city = str(hints.get("address_city", "")).strip() | ||
| if address_city: | ||
| payload["addressCity"] = address_city | ||
| seniority = str(hints.get("seniority_level", "")).strip() | ||
| if seniority: | ||
| payload["cSeniority"] = seniority | ||
| if description: | ||
| payload["description"] = description | ||
| if skills: | ||
| normalized_skills = [ | ||
| str(item).strip() for item in skills if str(item).strip() | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -34,7 +34,7 @@ class Settings(SharedSettings): | ||||||
| migadu_mailbox_domain: str = "508.dev" | ||||||
| openai_api_key: str | None = None | ||||||
| openai_base_url: str | None = None | ||||||
| openai_model: str = "gpt-4o-mini" | ||||||
| openai_model: str = "5o-mini" | ||||||
CopilotAI | ||||||
| openai_model: str="5o-mini" | |
| openai_model: str="gpt-4o-mini" |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -22,8 +22,8 @@ class WorkerSettings(SharedSettings): | ||||||||||
| openai_api_key: str | None = None | ||||||||||
| openai_base_url: str | None = None | ||||||||||
| openai_model: str = "gpt-4o-mini" | ||||||||||
| resume_ai_model: str = "gpt-4o-mini" | ||||||||||
| openai_model: str = "5o-mini" | ||||||||||
| resume_ai_model: str = "5o-mini" | ||||||||||
Comment on lines
+25
to
+26
CopilotAI | ||||||||||
| openai_model: str="5o-mini" | |
| resume_ai_model: str="5o-mini" | |
| openai_model: str="gpt-4o-mini" | |
| resume_ai_model: str="gpt-4o-mini" |
CopilotAIMar 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The model name "5o-mini" is not a valid OpenAI model identifier — it is a truncation of "gpt-4o-mini". This will cause API calls to fail at runtime.
| return"5o-mini" | |
| return"gpt-4o-mini" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -33,6 +33,8 @@ | ||
| "linkedin_url": settings.crm_linkedin_field, | ||
| "github_username": "cGitHubUsername", | ||
| "address_country": "addressCountry", | ||
| "address_city": "addressCity", | ||
| "timezone": "cTimezone", | ||
Comment on lines
+36
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Normalize new Line 326 currently routes these new keys through Proposed fix in `_build_intake_updates` for local_key, crm_field in FIELD_MAP.items():
if local_key == "github_username":
value = self._normalize_github_username(payload.get(local_key))
+ elif local_key == "timezone":+ value = self._normalize_timezone(payload.get(local_key))+ elif local_key == "address_city":+ value = self._normalize_city(payload.get(local_key))
elif local_key == "primary_role":
normalized_roles = self._parse_roles(payload.get(local_key))
if not normalized_roles:
continue
updates[crm_field] = normalized_rolesAlso applies to: 463-466 🤖 Prompt for AI Agents | ||
| "primary_role": "cRoles", | ||
Comment on lines
+36
to
38
CopilotAI | ||
| "availability": "cAvailableTimes", | ||
| "rate_range": "cRateRange", | ||
| @@ -434,6 +436,12 @@ def _build_resume_updates(self, payload: Mapping[str, Any]) -> dict[str, Any]: | ||
| profile_phone = self._normalize_text(extracted_profile.phone) | ||
| profile_github = self._normalize_text(extracted_profile.github_username) | ||
| profile_linkedin = self._normalize_text(extracted_profile.linkedin_url) | ||
| profile_timezone = self._normalize_timezone( | ||
| getattr(extracted_profile, "timezone", None) | ||
| ) | ||
| profile_city = self._normalize_city( | ||
| getattr(extracted_profile, "address_city", None) | ||
| ) | ||
| profile_availability = self._normalize_text( | ||
| getattr(extracted_profile, "availability", None) | ||
| ) | ||
| @@ -443,18 +451,35 @@ def _build_resume_updates(self, payload: Mapping[str, Any]) -> dict[str, Any]: | ||
| profile_referred_by = self._normalize_text( | ||
| getattr(extracted_profile, "referred_by", None) | ||
| ) | ||
| profile_description = self._normalize_text( | ||
| getattr(extracted_profile, "description", None) | ||
| ) | ||
| if profile_phone: | ||
| updates["phoneNumber"] = profile_phone | ||
| if profile_github: | ||
| updates["cGitHubUsername"] = profile_github | ||
| if profile_linkedin: | ||
| updates[settings.crm_linkedin_field] = profile_linkedin | ||
| if profile_timezone: | ||
| updates.setdefault("cTimezone", profile_timezone) | ||
| if profile_city: | ||
| updates.setdefault("addressCity", profile_city) | ||
| profile_country = self._normalize_text(extracted_profile.address_country) | ||
| if profile_country: | ||
| updates.setdefault("addressCountry", profile_country) | ||
Comment on lines
+467
to
+469
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Normalize resume-derived country before persisting Line 467 uses Proposed fix- profile_country = self._normalize_text(extracted_profile.address_country)+ profile_country = self._normalize_country(+ getattr(extracted_profile, "address_country", None)+ )
if profile_country:
updates.setdefault("addressCountry", profile_country)def_normalize_country(self, value: object) ->str|None:
normalized=self._normalize_text(value)
returnnormalized.title() ifnormalizedelseNone🤖 Prompt for AI Agents | ||
| profile_roles = self._parse_roles( | ||
| getattr(extracted_profile, "primary_roles", []) | ||
| ) | ||
| if profile_roles: | ||
| updates.setdefault("cRoles", profile_roles) | ||
| if profile_availability: | ||
| updates.setdefault("cAvailableTimes", profile_availability) | ||
| if profile_rate_range: | ||
| updates.setdefault("cRateRange", profile_rate_range) | ||
| if profile_referred_by: | ||
| updates.setdefault("cReferredBy", profile_referred_by) | ||
| if profile_description: | ||
| updates.setdefault("description", profile_description) | ||
| profile_attrs = self._parse_profile_skill_attrs(extracted_profile) | ||
| if profile_attrs: | ||
| updates["cSkillAttrs"] = json.dumps(profile_attrs) | ||
| @@ -599,6 +624,51 @@ def _normalize_text(self, value: object) -> str | None: | ||
| normalized = value.strip() | ||
| return normalized or None | ||
| def _normalize_timezone(self, value: object) -> str | None: | ||
| if not isinstance(value, str): | ||
| return None | ||
| raw = value.strip().replace(" ", "") | ||
| if not raw: | ||
| return None | ||
| pattern = re.search( | ||
| r"(?i)\b(?:utc|gmt)\s*([+-]\d{1,2}(?:[:.]?[0-5]?\d)?)\b", raw | ||
| ) | ||
| if pattern: | ||
| raw = pattern.group(1) | ||
| if raw.lower() in {"utc", "gmt"}: | ||
| return "UTC+00:00" | ||
| if raw[0] not in {"+", "-"}: | ||
| return None | ||
| match = re.match(r"([+-])(\d{1,2})(?::?([0-5]?\d))?$", raw) | ||
| if not match: | ||
| return None | ||
| sign = match.group(1) | ||
| try: | ||
| hours = int(match.group(2)) | ||
| except Exception: | ||
| return None | ||
| if not 0 <= hours <= 14: | ||
| return None | ||
| minutes = match.group(3) | ||
| if minutes is None: | ||
| minutes_value = 0 | ||
| else: | ||
| minutes_value = int(minutes) | ||
| if minutes_value > 59: | ||
| return None | ||
| return f"UTC{sign}{hours:02d}:{minutes_value:02d}" | ||
| def _normalize_city(self, value: object) -> str | None: | ||
| if not isinstance(value, str): | ||
| return None | ||
| normalized = value.strip() | ||
| if not normalized: | ||
| return None | ||
| normalized = normalized.split(",")[0].strip() | ||
| if not normalized: | ||
| return None | ||
| return " ".join(part.strip().title() for part in normalized.split()) | ||
Comment on lines
+627
to
+670
CopilotAI | ||
| def _normalize_github_username(self, value: object) -> str | None: | ||
| normalized = self._normalize_text(value) | ||
| if normalized is None: | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep
.env.examplekey order lint-clean.dotenv-linterreportsUnorderedKeyfor Lines 99 and 101 because both keys are placed afterRESUME_KEYWORDS(Line 95). Reordering avoids CI lint noise.Proposed reorder
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 99-99: [UnorderedKey] The OPENAI_MODEL key should go before the RESUME_KEYWORDS key
(UnorderedKey)
[warning] 101-101: [UnorderedKey] The RESUME_AI_MODEL key should go before the RESUME_KEYWORDS key
(UnorderedKey)
🤖 Prompt for AI Agents