Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep .env.example key order lint-clean.

dotenv-linter reports UnorderedKey for Lines 99 and 101 because both keys are placed after RESUME_KEYWORDS (Line 95). Reordering avoids CI lint noise.

Proposed reorder
-RESUME_KEYWORDS=resume,cv,curriculum
OPENAI_API_KEY=
# For OpenRouter, set OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_BASE_URL=
OPENAI_MODEL=5o-mini
# Resume model name without provider prefix; OpenRouter is auto-prefixed to openai/<model>
RESUME_AI_MODEL=5o-mini
+RESUME_KEYWORDS=resume,cv,curriculum
🧰 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
Verify each finding against the current code and only fix it if needed.
In @.env.example around lines 99 - 101, The dotenv-linter UnorderedKey error is
caused by OPENAI_MODEL and RESUME_AI_MODEL appearing after RESUME_KEYWORDS; fix
it by reordering the keys so the three entries are in the expected lexical order
(place OPENAI_MODEL and RESUME_AI_MODEL before RESUME_KEYWORDS), ensuring the
.env example key ordering matches dotenv-linter expectations.

Comment on lines +99 to +101

CopilotAIMar 3, 2026

Copy link

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" in the example environment file does not correspond to any known OpenAI model. It appears to be a truncation of "gpt-4o-mini". Additionally, the documentation files README.md and ENVIRONMENT.md still document the default as gpt-4o-mini, creating an inconsistency with this change.

Copilot uses AI. Check for mistakes.
RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
Expand Down
68 changes: 67 additions & 1 deletion apps/discord_bot/src/five08/discord_bot/cogs/crm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix timezone parser to accept dot-separated offsets it already matches.

Line [3295] allows offsets like UTC+5.30, but Line [3304] only parses : or no separator, so those inputs are silently dropped.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` around lines 3294 -
3305, The timezone parser accepts dot-separated offsets in utc_pattern but the
detailed parse regex (match) only allows ":" or no separator; update the parsing
to accept a dot as a valid separator so inputs like "UTC+5.30" are parsed.
Concretely, change the regex used in the re.match call (the one building match
from raw) to allow either ":" or "." between hours and minutes (e.g., use [:.]
where the separator is currently optional colon), leaving the surrounding logic
(utc_pattern, the raw = utc_pattern.group(1), the raw[0] sign check) unchanged.

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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone static method added to CrmCog duplicates the logic already implemented in intake_form_processor.py and resume_profile_processor.py, and the shared _normalize_timezone module-level function in resume_extractor.py. The Discord bot already imports from the shared package (from five08.resume_extractor import ...) — importing and reusing the shared _normalize_timezone function would eliminate this duplication.

Copilot uses AI. Check for mistakes.

def _build_inference_lookup_summary(
self, *, file_content: bytes, attempts: list[dict[str, Any]] | None
) -> str:
Expand DownExpand Up@@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent "None" from being written into CRM optional fields.

At Line [3430] and Line [3473], str(hints.get(...)).strip() converts missing values to the literal string "None", which then gets persisted to CRM.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` at line 3430, The code
converts optional CRM fields like description using str(hints.get("description",
"")).strip() which yields the literal "None" when the dict contains None; change
to explicitly guard against None (and other non-values) by doing something like:
val = hints.get("description"); description = "" if val is None else
str(val).strip(); apply the same pattern to the other optional fields that use
hints.get(...) at the block around lines 3473-3475 so that None is converted to
an empty string before persisting.

if not isinstance(emails, list):
emails = []
if not isinstance(github_usernames, list):
Expand All@@ -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,
}
Expand All@@ -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()
Expand Down
2 changes: 1 addition & 1 deletion apps/discord_bot/src/five08/discord_bot/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Kimai time tracking settings
kimai_base_url: str
Expand Down
6 changes: 3 additions & 3 deletions apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
resume_ai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"
resume_ai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

Expand DownExpand Up@@ -157,7 +157,7 @@ def resolved_resume_ai_model(self) -> str:
if not candidate:
candidate = self.openai_model.strip()
if not candidate:
return "gpt-4o-mini"
return "5o-mini"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
return"5o-mini"
return"gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Keep explicit provider prefixes intact.
if "/" in candidate:
Expand Down
70 changes: 70 additions & 0 deletions apps/worker/src/five08/worker/crm/intake_form_processor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize new timezone and address_city form values before CRM mapping.

Line 326 currently routes these new keys through _normalize_text, so raw values can persist and also block normalized resume-derived values during merge (Line 361).

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_roles

Also applies to: 463-466

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 36 -
37, In _build_intake_updates, normalize the incoming form values for the keys
"address_city" and "timezone" (call self._normalize_text on those form fields)
and store the normalized values into the intake_updates mapping so the
normalized form values are used in subsequent merge logic; also ensure the same
change is applied to the other occurrence of those keys in the method (the
second mapping block) so resume-derived normalized values can correctly
override/merge with form input. Include references to the keys "address_city"
and "timezone", the method _build_intake_updates, and the normalizer method
_normalize_text when making the change.

"primary_role": "cRoles",
Comment on lines +36 to 38

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The address_city and timezone keys added to FIELD_MAP (lines 36–37) are processed by the generic _normalize_text branch in _build_intake_updates (line 336), which only strips whitespace. This means form-submitted city values like "New York, NY" will be stored verbatim rather than normalized to just "New York", and timezone values like "UTC+5" won't be converted to the canonical "UTC+05:00" format. The other code paths (resume extraction) correctly apply _normalize_city and _normalize_timezone, creating inconsistent data. The loop in _build_intake_updates should include explicit normalization cases for these two keys, similar to how github_username and primary_role receive special handling.

Copilot uses AI. Check for mistakes.
"availability": "cAvailableTimes",
"rate_range": "cRateRange",
Expand DownExpand Up@@ -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)
)
Expand All@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize resume-derived country before persisting addressCountry.

Line 467 uses _normalize_text only, so casing can drift (united states vs United States) even though other inferred fields are normalized.

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
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 467
- 469, The addressCountry value is only run through _normalize_text which leaves
casing inconsistent; add a new helper _normalize_country(value) that calls
_normalize_text(value) and returns normalized.title() (or None) and then replace
the usage of _normalize_text(extracted_profile.address_country) with
_normalize_country(extracted_profile.address_country) when setting
updates.setdefault("addressCountry", ...); reference the new _normalize_country
function and the existing _normalize_text and extracted_profile.address_country
symbols when making the change.

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)
Expand DownExpand Up@@ -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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone and _normalize_city methods added to IntakeFormProcessor are instance methods (implicitly using self) but don't reference self at all. They should be @staticmethod for consistency with similar pure utility methods in ResumeProfileProcessor (e.g., _normalize_country, _normalize_city, _normalize_timezone at lines 827–886 of resume_profile_processor.py).

Copilot uses AI. Check for mistakes.

def _normalize_github_username(self, value: object) -> str | None:
normalized = self._normalize_text(value)
if normalized is None:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat: enrich resume extraction and crm mapping by michaelmwu · Pull Request #134 · 508-dev/508-workflows · GitHub
Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep .env.example key order lint-clean.

dotenv-linter reports UnorderedKey for Lines 99 and 101 because both keys are placed after RESUME_KEYWORDS (Line 95). Reordering avoids CI lint noise.

Proposed reorder
-RESUME_KEYWORDS=resume,cv,curriculum
OPENAI_API_KEY=
# For OpenRouter, set OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_BASE_URL=
OPENAI_MODEL=5o-mini
# Resume model name without provider prefix; OpenRouter is auto-prefixed to openai/<model>
RESUME_AI_MODEL=5o-mini
+RESUME_KEYWORDS=resume,cv,curriculum
🧰 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
Verify each finding against the current code and only fix it if needed.
In @.env.example around lines 99 - 101, The dotenv-linter UnorderedKey error is
caused by OPENAI_MODEL and RESUME_AI_MODEL appearing after RESUME_KEYWORDS; fix
it by reordering the keys so the three entries are in the expected lexical order
(place OPENAI_MODEL and RESUME_AI_MODEL before RESUME_KEYWORDS), ensuring the
.env example key ordering matches dotenv-linter expectations.

Comment on lines +99 to +101

CopilotAIMar 3, 2026

Copy link

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" in the example environment file does not correspond to any known OpenAI model. It appears to be a truncation of "gpt-4o-mini". Additionally, the documentation files README.md and ENVIRONMENT.md still document the default as gpt-4o-mini, creating an inconsistency with this change.

Copilot uses AI. Check for mistakes.
RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
Expand Down
68 changes: 67 additions & 1 deletion apps/discord_bot/src/five08/discord_bot/cogs/crm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix timezone parser to accept dot-separated offsets it already matches.

Line [3295] allows offsets like UTC+5.30, but Line [3304] only parses : or no separator, so those inputs are silently dropped.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` around lines 3294 -
3305, The timezone parser accepts dot-separated offsets in utc_pattern but the
detailed parse regex (match) only allows ":" or no separator; update the parsing
to accept a dot as a valid separator so inputs like "UTC+5.30" are parsed.
Concretely, change the regex used in the re.match call (the one building match
from raw) to allow either ":" or "." between hours and minutes (e.g., use [:.]
where the separator is currently optional colon), leaving the surrounding logic
(utc_pattern, the raw = utc_pattern.group(1), the raw[0] sign check) unchanged.

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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone static method added to CrmCog duplicates the logic already implemented in intake_form_processor.py and resume_profile_processor.py, and the shared _normalize_timezone module-level function in resume_extractor.py. The Discord bot already imports from the shared package (from five08.resume_extractor import ...) — importing and reusing the shared _normalize_timezone function would eliminate this duplication.

Copilot uses AI. Check for mistakes.

def _build_inference_lookup_summary(
self, *, file_content: bytes, attempts: list[dict[str, Any]] | None
) -> str:
Expand DownExpand Up@@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent "None" from being written into CRM optional fields.

At Line [3430] and Line [3473], str(hints.get(...)).strip() converts missing values to the literal string "None", which then gets persisted to CRM.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` at line 3430, The code
converts optional CRM fields like description using str(hints.get("description",
"")).strip() which yields the literal "None" when the dict contains None; change
to explicitly guard against None (and other non-values) by doing something like:
val = hints.get("description"); description = "" if val is None else
str(val).strip(); apply the same pattern to the other optional fields that use
hints.get(...) at the block around lines 3473-3475 so that None is converted to
an empty string before persisting.

if not isinstance(emails, list):
emails = []
if not isinstance(github_usernames, list):
Expand All@@ -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,
}
Expand All@@ -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()
Expand Down
2 changes: 1 addition & 1 deletion apps/discord_bot/src/five08/discord_bot/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Kimai time tracking settings
kimai_base_url: str
Expand Down
6 changes: 3 additions & 3 deletions apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
resume_ai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"
resume_ai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

Expand DownExpand Up@@ -157,7 +157,7 @@ def resolved_resume_ai_model(self) -> str:
if not candidate:
candidate = self.openai_model.strip()
if not candidate:
return "gpt-4o-mini"
return "5o-mini"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
return"5o-mini"
return"gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Keep explicit provider prefixes intact.
if "/" in candidate:
Expand Down
70 changes: 70 additions & 0 deletions apps/worker/src/five08/worker/crm/intake_form_processor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize new timezone and address_city form values before CRM mapping.

Line 326 currently routes these new keys through _normalize_text, so raw values can persist and also block normalized resume-derived values during merge (Line 361).

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_roles

Also applies to: 463-466

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 36 -
37, In _build_intake_updates, normalize the incoming form values for the keys
"address_city" and "timezone" (call self._normalize_text on those form fields)
and store the normalized values into the intake_updates mapping so the
normalized form values are used in subsequent merge logic; also ensure the same
change is applied to the other occurrence of those keys in the method (the
second mapping block) so resume-derived normalized values can correctly
override/merge with form input. Include references to the keys "address_city"
and "timezone", the method _build_intake_updates, and the normalizer method
_normalize_text when making the change.

"primary_role": "cRoles",
Comment on lines +36 to 38

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The address_city and timezone keys added to FIELD_MAP (lines 36–37) are processed by the generic _normalize_text branch in _build_intake_updates (line 336), which only strips whitespace. This means form-submitted city values like "New York, NY" will be stored verbatim rather than normalized to just "New York", and timezone values like "UTC+5" won't be converted to the canonical "UTC+05:00" format. The other code paths (resume extraction) correctly apply _normalize_city and _normalize_timezone, creating inconsistent data. The loop in _build_intake_updates should include explicit normalization cases for these two keys, similar to how github_username and primary_role receive special handling.

Copilot uses AI. Check for mistakes.
"availability": "cAvailableTimes",
"rate_range": "cRateRange",
Expand DownExpand Up@@ -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)
)
Expand All@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize resume-derived country before persisting addressCountry.

Line 467 uses _normalize_text only, so casing can drift (united states vs United States) even though other inferred fields are normalized.

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
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 467
- 469, The addressCountry value is only run through _normalize_text which leaves
casing inconsistent; add a new helper _normalize_country(value) that calls
_normalize_text(value) and returns normalized.title() (or None) and then replace
the usage of _normalize_text(extracted_profile.address_country) with
_normalize_country(extracted_profile.address_country) when setting
updates.setdefault("addressCountry", ...); reference the new _normalize_country
function and the existing _normalize_text and extracted_profile.address_country
symbols when making the change.

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)
Expand DownExpand Up@@ -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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone and _normalize_city methods added to IntakeFormProcessor are instance methods (implicitly using self) but don't reference self at all. They should be @staticmethod for consistency with similar pure utility methods in ResumeProfileProcessor (e.g., _normalize_country, _normalize_city, _normalize_timezone at lines 827–886 of resume_profile_processor.py).

Copilot uses AI. Check for mistakes.

def _normalize_github_username(self, value: object) -> str | None:
normalized = self._normalize_text(value)
if normalized is None:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: enrich resume extraction and crm mapping by michaelmwu · Pull Request #134 · 508-dev/508-workflows · GitHub
Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep .env.example key order lint-clean.

dotenv-linter reports UnorderedKey for Lines 99 and 101 because both keys are placed after RESUME_KEYWORDS (Line 95). Reordering avoids CI lint noise.

Proposed reorder
-RESUME_KEYWORDS=resume,cv,curriculum
OPENAI_API_KEY=
# For OpenRouter, set OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_BASE_URL=
OPENAI_MODEL=5o-mini
# Resume model name without provider prefix; OpenRouter is auto-prefixed to openai/<model>
RESUME_AI_MODEL=5o-mini
+RESUME_KEYWORDS=resume,cv,curriculum
🧰 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
Verify each finding against the current code and only fix it if needed.
In @.env.example around lines 99 - 101, The dotenv-linter UnorderedKey error is
caused by OPENAI_MODEL and RESUME_AI_MODEL appearing after RESUME_KEYWORDS; fix
it by reordering the keys so the three entries are in the expected lexical order
(place OPENAI_MODEL and RESUME_AI_MODEL before RESUME_KEYWORDS), ensuring the
.env example key ordering matches dotenv-linter expectations.

Comment on lines +99 to +101

CopilotAIMar 3, 2026

Copy link

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" in the example environment file does not correspond to any known OpenAI model. It appears to be a truncation of "gpt-4o-mini". Additionally, the documentation files README.md and ENVIRONMENT.md still document the default as gpt-4o-mini, creating an inconsistency with this change.

Copilot uses AI. Check for mistakes.
RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
Expand Down
68 changes: 67 additions & 1 deletion apps/discord_bot/src/five08/discord_bot/cogs/crm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix timezone parser to accept dot-separated offsets it already matches.

Line [3295] allows offsets like UTC+5.30, but Line [3304] only parses : or no separator, so those inputs are silently dropped.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` around lines 3294 -
3305, The timezone parser accepts dot-separated offsets in utc_pattern but the
detailed parse regex (match) only allows ":" or no separator; update the parsing
to accept a dot as a valid separator so inputs like "UTC+5.30" are parsed.
Concretely, change the regex used in the re.match call (the one building match
from raw) to allow either ":" or "." between hours and minutes (e.g., use [:.]
where the separator is currently optional colon), leaving the surrounding logic
(utc_pattern, the raw = utc_pattern.group(1), the raw[0] sign check) unchanged.

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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone static method added to CrmCog duplicates the logic already implemented in intake_form_processor.py and resume_profile_processor.py, and the shared _normalize_timezone module-level function in resume_extractor.py. The Discord bot already imports from the shared package (from five08.resume_extractor import ...) — importing and reusing the shared _normalize_timezone function would eliminate this duplication.

Copilot uses AI. Check for mistakes.

def _build_inference_lookup_summary(
self, *, file_content: bytes, attempts: list[dict[str, Any]] | None
) -> str:
Expand DownExpand Up@@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent "None" from being written into CRM optional fields.

At Line [3430] and Line [3473], str(hints.get(...)).strip() converts missing values to the literal string "None", which then gets persisted to CRM.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` at line 3430, The code
converts optional CRM fields like description using str(hints.get("description",
"")).strip() which yields the literal "None" when the dict contains None; change
to explicitly guard against None (and other non-values) by doing something like:
val = hints.get("description"); description = "" if val is None else
str(val).strip(); apply the same pattern to the other optional fields that use
hints.get(...) at the block around lines 3473-3475 so that None is converted to
an empty string before persisting.

if not isinstance(emails, list):
emails = []
if not isinstance(github_usernames, list):
Expand All@@ -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,
}
Expand All@@ -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()
Expand Down
2 changes: 1 addition & 1 deletion apps/discord_bot/src/five08/discord_bot/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Kimai time tracking settings
kimai_base_url: str
Expand Down
6 changes: 3 additions & 3 deletions apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
resume_ai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"
resume_ai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

Expand DownExpand Up@@ -157,7 +157,7 @@ def resolved_resume_ai_model(self) -> str:
if not candidate:
candidate = self.openai_model.strip()
if not candidate:
return "gpt-4o-mini"
return "5o-mini"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
return"5o-mini"
return"gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Keep explicit provider prefixes intact.
if "/" in candidate:
Expand Down
70 changes: 70 additions & 0 deletions apps/worker/src/five08/worker/crm/intake_form_processor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize new timezone and address_city form values before CRM mapping.

Line 326 currently routes these new keys through _normalize_text, so raw values can persist and also block normalized resume-derived values during merge (Line 361).

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_roles

Also applies to: 463-466

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 36 -
37, In _build_intake_updates, normalize the incoming form values for the keys
"address_city" and "timezone" (call self._normalize_text on those form fields)
and store the normalized values into the intake_updates mapping so the
normalized form values are used in subsequent merge logic; also ensure the same
change is applied to the other occurrence of those keys in the method (the
second mapping block) so resume-derived normalized values can correctly
override/merge with form input. Include references to the keys "address_city"
and "timezone", the method _build_intake_updates, and the normalizer method
_normalize_text when making the change.

"primary_role": "cRoles",
Comment on lines +36 to 38

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The address_city and timezone keys added to FIELD_MAP (lines 36–37) are processed by the generic _normalize_text branch in _build_intake_updates (line 336), which only strips whitespace. This means form-submitted city values like "New York, NY" will be stored verbatim rather than normalized to just "New York", and timezone values like "UTC+5" won't be converted to the canonical "UTC+05:00" format. The other code paths (resume extraction) correctly apply _normalize_city and _normalize_timezone, creating inconsistent data. The loop in _build_intake_updates should include explicit normalization cases for these two keys, similar to how github_username and primary_role receive special handling.

Copilot uses AI. Check for mistakes.
"availability": "cAvailableTimes",
"rate_range": "cRateRange",
Expand DownExpand Up@@ -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)
)
Expand All@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize resume-derived country before persisting addressCountry.

Line 467 uses _normalize_text only, so casing can drift (united states vs United States) even though other inferred fields are normalized.

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
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 467
- 469, The addressCountry value is only run through _normalize_text which leaves
casing inconsistent; add a new helper _normalize_country(value) that calls
_normalize_text(value) and returns normalized.title() (or None) and then replace
the usage of _normalize_text(extracted_profile.address_country) with
_normalize_country(extracted_profile.address_country) when setting
updates.setdefault("addressCountry", ...); reference the new _normalize_country
function and the existing _normalize_text and extracted_profile.address_country
symbols when making the change.

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)
Expand DownExpand Up@@ -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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone and _normalize_city methods added to IntakeFormProcessor are instance methods (implicitly using self) but don't reference self at all. They should be @staticmethod for consistency with similar pure utility methods in ResumeProfileProcessor (e.g., _normalize_country, _normalize_city, _normalize_timezone at lines 827–886 of resume_profile_processor.py).

Copilot uses AI. Check for mistakes.

def _normalize_github_username(self, value: object) -> str | None:
normalized = self._normalize_text(value)
if normalized is None:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: enrich resume extraction and crm mapping by michaelmwu · Pull Request #134 · 508-dev/508-workflows · GitHub
Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep .env.example key order lint-clean.

dotenv-linter reports UnorderedKey for Lines 99 and 101 because both keys are placed after RESUME_KEYWORDS (Line 95). Reordering avoids CI lint noise.

Proposed reorder
-RESUME_KEYWORDS=resume,cv,curriculum
OPENAI_API_KEY=
# For OpenRouter, set OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_BASE_URL=
OPENAI_MODEL=5o-mini
# Resume model name without provider prefix; OpenRouter is auto-prefixed to openai/<model>
RESUME_AI_MODEL=5o-mini
+RESUME_KEYWORDS=resume,cv,curriculum
🧰 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
Verify each finding against the current code and only fix it if needed.
In @.env.example around lines 99 - 101, The dotenv-linter UnorderedKey error is
caused by OPENAI_MODEL and RESUME_AI_MODEL appearing after RESUME_KEYWORDS; fix
it by reordering the keys so the three entries are in the expected lexical order
(place OPENAI_MODEL and RESUME_AI_MODEL before RESUME_KEYWORDS), ensuring the
.env example key ordering matches dotenv-linter expectations.

Comment on lines +99 to +101

CopilotAIMar 3, 2026

Copy link

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" in the example environment file does not correspond to any known OpenAI model. It appears to be a truncation of "gpt-4o-mini". Additionally, the documentation files README.md and ENVIRONMENT.md still document the default as gpt-4o-mini, creating an inconsistency with this change.

Copilot uses AI. Check for mistakes.
RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
Expand Down
68 changes: 67 additions & 1 deletion apps/discord_bot/src/five08/discord_bot/cogs/crm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix timezone parser to accept dot-separated offsets it already matches.

Line [3295] allows offsets like UTC+5.30, but Line [3304] only parses : or no separator, so those inputs are silently dropped.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` around lines 3294 -
3305, The timezone parser accepts dot-separated offsets in utc_pattern but the
detailed parse regex (match) only allows ":" or no separator; update the parsing
to accept a dot as a valid separator so inputs like "UTC+5.30" are parsed.
Concretely, change the regex used in the re.match call (the one building match
from raw) to allow either ":" or "." between hours and minutes (e.g., use [:.]
where the separator is currently optional colon), leaving the surrounding logic
(utc_pattern, the raw = utc_pattern.group(1), the raw[0] sign check) unchanged.

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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone static method added to CrmCog duplicates the logic already implemented in intake_form_processor.py and resume_profile_processor.py, and the shared _normalize_timezone module-level function in resume_extractor.py. The Discord bot already imports from the shared package (from five08.resume_extractor import ...) — importing and reusing the shared _normalize_timezone function would eliminate this duplication.

Copilot uses AI. Check for mistakes.

def _build_inference_lookup_summary(
self, *, file_content: bytes, attempts: list[dict[str, Any]] | None
) -> str:
Expand DownExpand Up@@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent "None" from being written into CRM optional fields.

At Line [3430] and Line [3473], str(hints.get(...)).strip() converts missing values to the literal string "None", which then gets persisted to CRM.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` at line 3430, The code
converts optional CRM fields like description using str(hints.get("description",
"")).strip() which yields the literal "None" when the dict contains None; change
to explicitly guard against None (and other non-values) by doing something like:
val = hints.get("description"); description = "" if val is None else
str(val).strip(); apply the same pattern to the other optional fields that use
hints.get(...) at the block around lines 3473-3475 so that None is converted to
an empty string before persisting.

if not isinstance(emails, list):
emails = []
if not isinstance(github_usernames, list):
Expand All@@ -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,
}
Expand All@@ -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()
Expand Down
2 changes: 1 addition & 1 deletion apps/discord_bot/src/five08/discord_bot/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Kimai time tracking settings
kimai_base_url: str
Expand Down
6 changes: 3 additions & 3 deletions apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
resume_ai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"
resume_ai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

Expand DownExpand Up@@ -157,7 +157,7 @@ def resolved_resume_ai_model(self) -> str:
if not candidate:
candidate = self.openai_model.strip()
if not candidate:
return "gpt-4o-mini"
return "5o-mini"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
return"5o-mini"
return"gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Keep explicit provider prefixes intact.
if "/" in candidate:
Expand Down
70 changes: 70 additions & 0 deletions apps/worker/src/five08/worker/crm/intake_form_processor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize new timezone and address_city form values before CRM mapping.

Line 326 currently routes these new keys through _normalize_text, so raw values can persist and also block normalized resume-derived values during merge (Line 361).

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_roles

Also applies to: 463-466

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 36 -
37, In _build_intake_updates, normalize the incoming form values for the keys
"address_city" and "timezone" (call self._normalize_text on those form fields)
and store the normalized values into the intake_updates mapping so the
normalized form values are used in subsequent merge logic; also ensure the same
change is applied to the other occurrence of those keys in the method (the
second mapping block) so resume-derived normalized values can correctly
override/merge with form input. Include references to the keys "address_city"
and "timezone", the method _build_intake_updates, and the normalizer method
_normalize_text when making the change.

"primary_role": "cRoles",
Comment on lines +36 to 38

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The address_city and timezone keys added to FIELD_MAP (lines 36–37) are processed by the generic _normalize_text branch in _build_intake_updates (line 336), which only strips whitespace. This means form-submitted city values like "New York, NY" will be stored verbatim rather than normalized to just "New York", and timezone values like "UTC+5" won't be converted to the canonical "UTC+05:00" format. The other code paths (resume extraction) correctly apply _normalize_city and _normalize_timezone, creating inconsistent data. The loop in _build_intake_updates should include explicit normalization cases for these two keys, similar to how github_username and primary_role receive special handling.

Copilot uses AI. Check for mistakes.
"availability": "cAvailableTimes",
"rate_range": "cRateRange",
Expand DownExpand Up@@ -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)
)
Expand All@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize resume-derived country before persisting addressCountry.

Line 467 uses _normalize_text only, so casing can drift (united states vs United States) even though other inferred fields are normalized.

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
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 467
- 469, The addressCountry value is only run through _normalize_text which leaves
casing inconsistent; add a new helper _normalize_country(value) that calls
_normalize_text(value) and returns normalized.title() (or None) and then replace
the usage of _normalize_text(extracted_profile.address_country) with
_normalize_country(extracted_profile.address_country) when setting
updates.setdefault("addressCountry", ...); reference the new _normalize_country
function and the existing _normalize_text and extracted_profile.address_country
symbols when making the change.

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)
Expand DownExpand Up@@ -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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone and _normalize_city methods added to IntakeFormProcessor are instance methods (implicitly using self) but don't reference self at all. They should be @staticmethod for consistency with similar pure utility methods in ResumeProfileProcessor (e.g., _normalize_country, _normalize_city, _normalize_timezone at lines 827–886 of resume_profile_processor.py).

Copilot uses AI. Check for mistakes.

def _normalize_github_username(self, value: object) -> str | None:
normalized = self._normalize_text(value)
if normalized is None:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat: enrich resume extraction and crm mapping by michaelmwu · Pull Request #134 · 508-dev/508-workflows · GitHub
Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep .env.example key order lint-clean.

dotenv-linter reports UnorderedKey for Lines 99 and 101 because both keys are placed after RESUME_KEYWORDS (Line 95). Reordering avoids CI lint noise.

Proposed reorder
-RESUME_KEYWORDS=resume,cv,curriculum
OPENAI_API_KEY=
# For OpenRouter, set OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_BASE_URL=
OPENAI_MODEL=5o-mini
# Resume model name without provider prefix; OpenRouter is auto-prefixed to openai/<model>
RESUME_AI_MODEL=5o-mini
+RESUME_KEYWORDS=resume,cv,curriculum
🧰 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
Verify each finding against the current code and only fix it if needed.
In @.env.example around lines 99 - 101, The dotenv-linter UnorderedKey error is
caused by OPENAI_MODEL and RESUME_AI_MODEL appearing after RESUME_KEYWORDS; fix
it by reordering the keys so the three entries are in the expected lexical order
(place OPENAI_MODEL and RESUME_AI_MODEL before RESUME_KEYWORDS), ensuring the
.env example key ordering matches dotenv-linter expectations.

Comment on lines +99 to +101

CopilotAIMar 3, 2026

Copy link

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" in the example environment file does not correspond to any known OpenAI model. It appears to be a truncation of "gpt-4o-mini". Additionally, the documentation files README.md and ENVIRONMENT.md still document the default as gpt-4o-mini, creating an inconsistency with this change.

Copilot uses AI. Check for mistakes.
RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
Expand Down
68 changes: 67 additions & 1 deletion apps/discord_bot/src/five08/discord_bot/cogs/crm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix timezone parser to accept dot-separated offsets it already matches.

Line [3295] allows offsets like UTC+5.30, but Line [3304] only parses : or no separator, so those inputs are silently dropped.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` around lines 3294 -
3305, The timezone parser accepts dot-separated offsets in utc_pattern but the
detailed parse regex (match) only allows ":" or no separator; update the parsing
to accept a dot as a valid separator so inputs like "UTC+5.30" are parsed.
Concretely, change the regex used in the re.match call (the one building match
from raw) to allow either ":" or "." between hours and minutes (e.g., use [:.]
where the separator is currently optional colon), leaving the surrounding logic
(utc_pattern, the raw = utc_pattern.group(1), the raw[0] sign check) unchanged.

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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone static method added to CrmCog duplicates the logic already implemented in intake_form_processor.py and resume_profile_processor.py, and the shared _normalize_timezone module-level function in resume_extractor.py. The Discord bot already imports from the shared package (from five08.resume_extractor import ...) — importing and reusing the shared _normalize_timezone function would eliminate this duplication.

Copilot uses AI. Check for mistakes.

def _build_inference_lookup_summary(
self, *, file_content: bytes, attempts: list[dict[str, Any]] | None
) -> str:
Expand DownExpand Up@@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent "None" from being written into CRM optional fields.

At Line [3430] and Line [3473], str(hints.get(...)).strip() converts missing values to the literal string "None", which then gets persisted to CRM.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` at line 3430, The code
converts optional CRM fields like description using str(hints.get("description",
"")).strip() which yields the literal "None" when the dict contains None; change
to explicitly guard against None (and other non-values) by doing something like:
val = hints.get("description"); description = "" if val is None else
str(val).strip(); apply the same pattern to the other optional fields that use
hints.get(...) at the block around lines 3473-3475 so that None is converted to
an empty string before persisting.

if not isinstance(emails, list):
emails = []
if not isinstance(github_usernames, list):
Expand All@@ -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,
}
Expand All@@ -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()
Expand Down
2 changes: 1 addition & 1 deletion apps/discord_bot/src/five08/discord_bot/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Kimai time tracking settings
kimai_base_url: str
Expand Down
6 changes: 3 additions & 3 deletions apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
resume_ai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"
resume_ai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

Expand DownExpand Up@@ -157,7 +157,7 @@ def resolved_resume_ai_model(self) -> str:
if not candidate:
candidate = self.openai_model.strip()
if not candidate:
return "gpt-4o-mini"
return "5o-mini"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
return"5o-mini"
return"gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Keep explicit provider prefixes intact.
if "/" in candidate:
Expand Down
70 changes: 70 additions & 0 deletions apps/worker/src/five08/worker/crm/intake_form_processor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize new timezone and address_city form values before CRM mapping.

Line 326 currently routes these new keys through _normalize_text, so raw values can persist and also block normalized resume-derived values during merge (Line 361).

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_roles

Also applies to: 463-466

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 36 -
37, In _build_intake_updates, normalize the incoming form values for the keys
"address_city" and "timezone" (call self._normalize_text on those form fields)
and store the normalized values into the intake_updates mapping so the
normalized form values are used in subsequent merge logic; also ensure the same
change is applied to the other occurrence of those keys in the method (the
second mapping block) so resume-derived normalized values can correctly
override/merge with form input. Include references to the keys "address_city"
and "timezone", the method _build_intake_updates, and the normalizer method
_normalize_text when making the change.

"primary_role": "cRoles",
Comment on lines +36 to 38

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The address_city and timezone keys added to FIELD_MAP (lines 36–37) are processed by the generic _normalize_text branch in _build_intake_updates (line 336), which only strips whitespace. This means form-submitted city values like "New York, NY" will be stored verbatim rather than normalized to just "New York", and timezone values like "UTC+5" won't be converted to the canonical "UTC+05:00" format. The other code paths (resume extraction) correctly apply _normalize_city and _normalize_timezone, creating inconsistent data. The loop in _build_intake_updates should include explicit normalization cases for these two keys, similar to how github_username and primary_role receive special handling.

Copilot uses AI. Check for mistakes.
"availability": "cAvailableTimes",
"rate_range": "cRateRange",
Expand DownExpand Up@@ -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)
)
Expand All@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize resume-derived country before persisting addressCountry.

Line 467 uses _normalize_text only, so casing can drift (united states vs United States) even though other inferred fields are normalized.

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
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 467
- 469, The addressCountry value is only run through _normalize_text which leaves
casing inconsistent; add a new helper _normalize_country(value) that calls
_normalize_text(value) and returns normalized.title() (or None) and then replace
the usage of _normalize_text(extracted_profile.address_country) with
_normalize_country(extracted_profile.address_country) when setting
updates.setdefault("addressCountry", ...); reference the new _normalize_country
function and the existing _normalize_text and extracted_profile.address_country
symbols when making the change.

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)
Expand DownExpand Up@@ -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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone and _normalize_city methods added to IntakeFormProcessor are instance methods (implicitly using self) but don't reference self at all. They should be @staticmethod for consistency with similar pure utility methods in ResumeProfileProcessor (e.g., _normalize_country, _normalize_city, _normalize_timezone at lines 827–886 of resume_profile_processor.py).

Copilot uses AI. Check for mistakes.

def _normalize_github_username(self, value: object) -> str | None:
normalized = self._normalize_text(value)
if normalized is None:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: enrich resume extraction and crm mapping by michaelmwu · Pull Request #134 · 508-dev/508-workflows · GitHub
Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep .env.example key order lint-clean.

dotenv-linter reports UnorderedKey for Lines 99 and 101 because both keys are placed after RESUME_KEYWORDS (Line 95). Reordering avoids CI lint noise.

Proposed reorder
-RESUME_KEYWORDS=resume,cv,curriculum
OPENAI_API_KEY=
# For OpenRouter, set OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_BASE_URL=
OPENAI_MODEL=5o-mini
# Resume model name without provider prefix; OpenRouter is auto-prefixed to openai/<model>
RESUME_AI_MODEL=5o-mini
+RESUME_KEYWORDS=resume,cv,curriculum
🧰 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
Verify each finding against the current code and only fix it if needed.
In @.env.example around lines 99 - 101, The dotenv-linter UnorderedKey error is
caused by OPENAI_MODEL and RESUME_AI_MODEL appearing after RESUME_KEYWORDS; fix
it by reordering the keys so the three entries are in the expected lexical order
(place OPENAI_MODEL and RESUME_AI_MODEL before RESUME_KEYWORDS), ensuring the
.env example key ordering matches dotenv-linter expectations.

Comment on lines +99 to +101

CopilotAIMar 3, 2026

Copy link

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" in the example environment file does not correspond to any known OpenAI model. It appears to be a truncation of "gpt-4o-mini". Additionally, the documentation files README.md and ENVIRONMENT.md still document the default as gpt-4o-mini, creating an inconsistency with this change.

Copilot uses AI. Check for mistakes.
RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
Expand Down
68 changes: 67 additions & 1 deletion apps/discord_bot/src/five08/discord_bot/cogs/crm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix timezone parser to accept dot-separated offsets it already matches.

Line [3295] allows offsets like UTC+5.30, but Line [3304] only parses : or no separator, so those inputs are silently dropped.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` around lines 3294 -
3305, The timezone parser accepts dot-separated offsets in utc_pattern but the
detailed parse regex (match) only allows ":" or no separator; update the parsing
to accept a dot as a valid separator so inputs like "UTC+5.30" are parsed.
Concretely, change the regex used in the re.match call (the one building match
from raw) to allow either ":" or "." between hours and minutes (e.g., use [:.]
where the separator is currently optional colon), leaving the surrounding logic
(utc_pattern, the raw = utc_pattern.group(1), the raw[0] sign check) unchanged.

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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone static method added to CrmCog duplicates the logic already implemented in intake_form_processor.py and resume_profile_processor.py, and the shared _normalize_timezone module-level function in resume_extractor.py. The Discord bot already imports from the shared package (from five08.resume_extractor import ...) — importing and reusing the shared _normalize_timezone function would eliminate this duplication.

Copilot uses AI. Check for mistakes.

def _build_inference_lookup_summary(
self, *, file_content: bytes, attempts: list[dict[str, Any]] | None
) -> str:
Expand DownExpand Up@@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent "None" from being written into CRM optional fields.

At Line [3430] and Line [3473], str(hints.get(...)).strip() converts missing values to the literal string "None", which then gets persisted to CRM.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` at line 3430, The code
converts optional CRM fields like description using str(hints.get("description",
"")).strip() which yields the literal "None" when the dict contains None; change
to explicitly guard against None (and other non-values) by doing something like:
val = hints.get("description"); description = "" if val is None else
str(val).strip(); apply the same pattern to the other optional fields that use
hints.get(...) at the block around lines 3473-3475 so that None is converted to
an empty string before persisting.

if not isinstance(emails, list):
emails = []
if not isinstance(github_usernames, list):
Expand All@@ -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,
}
Expand All@@ -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()
Expand Down
2 changes: 1 addition & 1 deletion apps/discord_bot/src/five08/discord_bot/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Kimai time tracking settings
kimai_base_url: str
Expand Down
6 changes: 3 additions & 3 deletions apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
resume_ai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"
resume_ai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

Expand DownExpand Up@@ -157,7 +157,7 @@ def resolved_resume_ai_model(self) -> str:
if not candidate:
candidate = self.openai_model.strip()
if not candidate:
return "gpt-4o-mini"
return "5o-mini"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
return"5o-mini"
return"gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Keep explicit provider prefixes intact.
if "/" in candidate:
Expand Down
70 changes: 70 additions & 0 deletions apps/worker/src/five08/worker/crm/intake_form_processor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize new timezone and address_city form values before CRM mapping.

Line 326 currently routes these new keys through _normalize_text, so raw values can persist and also block normalized resume-derived values during merge (Line 361).

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_roles

Also applies to: 463-466

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 36 -
37, In _build_intake_updates, normalize the incoming form values for the keys
"address_city" and "timezone" (call self._normalize_text on those form fields)
and store the normalized values into the intake_updates mapping so the
normalized form values are used in subsequent merge logic; also ensure the same
change is applied to the other occurrence of those keys in the method (the
second mapping block) so resume-derived normalized values can correctly
override/merge with form input. Include references to the keys "address_city"
and "timezone", the method _build_intake_updates, and the normalizer method
_normalize_text when making the change.

"primary_role": "cRoles",
Comment on lines +36 to 38

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The address_city and timezone keys added to FIELD_MAP (lines 36–37) are processed by the generic _normalize_text branch in _build_intake_updates (line 336), which only strips whitespace. This means form-submitted city values like "New York, NY" will be stored verbatim rather than normalized to just "New York", and timezone values like "UTC+5" won't be converted to the canonical "UTC+05:00" format. The other code paths (resume extraction) correctly apply _normalize_city and _normalize_timezone, creating inconsistent data. The loop in _build_intake_updates should include explicit normalization cases for these two keys, similar to how github_username and primary_role receive special handling.

Copilot uses AI. Check for mistakes.
"availability": "cAvailableTimes",
"rate_range": "cRateRange",
Expand DownExpand Up@@ -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)
)
Expand All@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize resume-derived country before persisting addressCountry.

Line 467 uses _normalize_text only, so casing can drift (united states vs United States) even though other inferred fields are normalized.

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
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 467
- 469, The addressCountry value is only run through _normalize_text which leaves
casing inconsistent; add a new helper _normalize_country(value) that calls
_normalize_text(value) and returns normalized.title() (or None) and then replace
the usage of _normalize_text(extracted_profile.address_country) with
_normalize_country(extracted_profile.address_country) when setting
updates.setdefault("addressCountry", ...); reference the new _normalize_country
function and the existing _normalize_text and extracted_profile.address_country
symbols when making the change.

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)
Expand DownExpand Up@@ -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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone and _normalize_city methods added to IntakeFormProcessor are instance methods (implicitly using self) but don't reference self at all. They should be @staticmethod for consistency with similar pure utility methods in ResumeProfileProcessor (e.g., _normalize_country, _normalize_city, _normalize_timezone at lines 827–886 of resume_profile_processor.py).

Copilot uses AI. Check for mistakes.

def _normalize_github_username(self, value: object) -> str | None:
normalized = self._normalize_text(value)
if normalized is None:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: enrich resume extraction and crm mapping by michaelmwu · Pull Request #134 · 508-dev/508-workflows · GitHub
Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep .env.example key order lint-clean.

dotenv-linter reports UnorderedKey for Lines 99 and 101 because both keys are placed after RESUME_KEYWORDS (Line 95). Reordering avoids CI lint noise.

Proposed reorder
-RESUME_KEYWORDS=resume,cv,curriculum
OPENAI_API_KEY=
# For OpenRouter, set OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_BASE_URL=
OPENAI_MODEL=5o-mini
# Resume model name without provider prefix; OpenRouter is auto-prefixed to openai/<model>
RESUME_AI_MODEL=5o-mini
+RESUME_KEYWORDS=resume,cv,curriculum
🧰 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
Verify each finding against the current code and only fix it if needed.
In @.env.example around lines 99 - 101, The dotenv-linter UnorderedKey error is
caused by OPENAI_MODEL and RESUME_AI_MODEL appearing after RESUME_KEYWORDS; fix
it by reordering the keys so the three entries are in the expected lexical order
(place OPENAI_MODEL and RESUME_AI_MODEL before RESUME_KEYWORDS), ensuring the
.env example key ordering matches dotenv-linter expectations.

Comment on lines +99 to +101

CopilotAIMar 3, 2026

Copy link

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" in the example environment file does not correspond to any known OpenAI model. It appears to be a truncation of "gpt-4o-mini". Additionally, the documentation files README.md and ENVIRONMENT.md still document the default as gpt-4o-mini, creating an inconsistency with this change.

Copilot uses AI. Check for mistakes.
RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
Expand Down
68 changes: 67 additions & 1 deletion apps/discord_bot/src/five08/discord_bot/cogs/crm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix timezone parser to accept dot-separated offsets it already matches.

Line [3295] allows offsets like UTC+5.30, but Line [3304] only parses : or no separator, so those inputs are silently dropped.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` around lines 3294 -
3305, The timezone parser accepts dot-separated offsets in utc_pattern but the
detailed parse regex (match) only allows ":" or no separator; update the parsing
to accept a dot as a valid separator so inputs like "UTC+5.30" are parsed.
Concretely, change the regex used in the re.match call (the one building match
from raw) to allow either ":" or "." between hours and minutes (e.g., use [:.]
where the separator is currently optional colon), leaving the surrounding logic
(utc_pattern, the raw = utc_pattern.group(1), the raw[0] sign check) unchanged.

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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone static method added to CrmCog duplicates the logic already implemented in intake_form_processor.py and resume_profile_processor.py, and the shared _normalize_timezone module-level function in resume_extractor.py. The Discord bot already imports from the shared package (from five08.resume_extractor import ...) — importing and reusing the shared _normalize_timezone function would eliminate this duplication.

Copilot uses AI. Check for mistakes.

def _build_inference_lookup_summary(
self, *, file_content: bytes, attempts: list[dict[str, Any]] | None
) -> str:
Expand DownExpand Up@@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent "None" from being written into CRM optional fields.

At Line [3430] and Line [3473], str(hints.get(...)).strip() converts missing values to the literal string "None", which then gets persisted to CRM.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` at line 3430, The code
converts optional CRM fields like description using str(hints.get("description",
"")).strip() which yields the literal "None" when the dict contains None; change
to explicitly guard against None (and other non-values) by doing something like:
val = hints.get("description"); description = "" if val is None else
str(val).strip(); apply the same pattern to the other optional fields that use
hints.get(...) at the block around lines 3473-3475 so that None is converted to
an empty string before persisting.

if not isinstance(emails, list):
emails = []
if not isinstance(github_usernames, list):
Expand All@@ -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,
}
Expand All@@ -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()
Expand Down
2 changes: 1 addition & 1 deletion apps/discord_bot/src/five08/discord_bot/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Kimai time tracking settings
kimai_base_url: str
Expand Down
6 changes: 3 additions & 3 deletions apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
resume_ai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"
resume_ai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

Expand DownExpand Up@@ -157,7 +157,7 @@ def resolved_resume_ai_model(self) -> str:
if not candidate:
candidate = self.openai_model.strip()
if not candidate:
return "gpt-4o-mini"
return "5o-mini"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
return"5o-mini"
return"gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Keep explicit provider prefixes intact.
if "/" in candidate:
Expand Down
70 changes: 70 additions & 0 deletions apps/worker/src/five08/worker/crm/intake_form_processor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize new timezone and address_city form values before CRM mapping.

Line 326 currently routes these new keys through _normalize_text, so raw values can persist and also block normalized resume-derived values during merge (Line 361).

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_roles

Also applies to: 463-466

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 36 -
37, In _build_intake_updates, normalize the incoming form values for the keys
"address_city" and "timezone" (call self._normalize_text on those form fields)
and store the normalized values into the intake_updates mapping so the
normalized form values are used in subsequent merge logic; also ensure the same
change is applied to the other occurrence of those keys in the method (the
second mapping block) so resume-derived normalized values can correctly
override/merge with form input. Include references to the keys "address_city"
and "timezone", the method _build_intake_updates, and the normalizer method
_normalize_text when making the change.

"primary_role": "cRoles",
Comment on lines +36 to 38

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The address_city and timezone keys added to FIELD_MAP (lines 36–37) are processed by the generic _normalize_text branch in _build_intake_updates (line 336), which only strips whitespace. This means form-submitted city values like "New York, NY" will be stored verbatim rather than normalized to just "New York", and timezone values like "UTC+5" won't be converted to the canonical "UTC+05:00" format. The other code paths (resume extraction) correctly apply _normalize_city and _normalize_timezone, creating inconsistent data. The loop in _build_intake_updates should include explicit normalization cases for these two keys, similar to how github_username and primary_role receive special handling.

Copilot uses AI. Check for mistakes.
"availability": "cAvailableTimes",
"rate_range": "cRateRange",
Expand DownExpand Up@@ -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)
)
Expand All@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize resume-derived country before persisting addressCountry.

Line 467 uses _normalize_text only, so casing can drift (united states vs United States) even though other inferred fields are normalized.

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
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 467
- 469, The addressCountry value is only run through _normalize_text which leaves
casing inconsistent; add a new helper _normalize_country(value) that calls
_normalize_text(value) and returns normalized.title() (or None) and then replace
the usage of _normalize_text(extracted_profile.address_country) with
_normalize_country(extracted_profile.address_country) when setting
updates.setdefault("addressCountry", ...); reference the new _normalize_country
function and the existing _normalize_text and extracted_profile.address_country
symbols when making the change.

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)
Expand DownExpand Up@@ -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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone and _normalize_city methods added to IntakeFormProcessor are instance methods (implicitly using self) but don't reference self at all. They should be @staticmethod for consistency with similar pure utility methods in ResumeProfileProcessor (e.g., _normalize_country, _normalize_city, _normalize_timezone at lines 827–886 of resume_profile_processor.py).

Copilot uses AI. Check for mistakes.

def _normalize_github_username(self, value: object) -> str | None:
normalized = self._normalize_text(value)
if normalized is None:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat: enrich resume extraction and crm mapping by michaelmwu · Pull Request #134 · 508-dev/508-workflows · GitHub
Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep .env.example key order lint-clean.

dotenv-linter reports UnorderedKey for Lines 99 and 101 because both keys are placed after RESUME_KEYWORDS (Line 95). Reordering avoids CI lint noise.

Proposed reorder
-RESUME_KEYWORDS=resume,cv,curriculum
OPENAI_API_KEY=
# For OpenRouter, set OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_BASE_URL=
OPENAI_MODEL=5o-mini
# Resume model name without provider prefix; OpenRouter is auto-prefixed to openai/<model>
RESUME_AI_MODEL=5o-mini
+RESUME_KEYWORDS=resume,cv,curriculum
🧰 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
Verify each finding against the current code and only fix it if needed.
In @.env.example around lines 99 - 101, The dotenv-linter UnorderedKey error is
caused by OPENAI_MODEL and RESUME_AI_MODEL appearing after RESUME_KEYWORDS; fix
it by reordering the keys so the three entries are in the expected lexical order
(place OPENAI_MODEL and RESUME_AI_MODEL before RESUME_KEYWORDS), ensuring the
.env example key ordering matches dotenv-linter expectations.

Comment on lines +99 to +101

CopilotAIMar 3, 2026

Copy link

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" in the example environment file does not correspond to any known OpenAI model. It appears to be a truncation of "gpt-4o-mini". Additionally, the documentation files README.md and ENVIRONMENT.md still document the default as gpt-4o-mini, creating an inconsistency with this change.

Copilot uses AI. Check for mistakes.
RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
Expand Down
68 changes: 67 additions & 1 deletion apps/discord_bot/src/five08/discord_bot/cogs/crm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix timezone parser to accept dot-separated offsets it already matches.

Line [3295] allows offsets like UTC+5.30, but Line [3304] only parses : or no separator, so those inputs are silently dropped.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` around lines 3294 -
3305, The timezone parser accepts dot-separated offsets in utc_pattern but the
detailed parse regex (match) only allows ":" or no separator; update the parsing
to accept a dot as a valid separator so inputs like "UTC+5.30" are parsed.
Concretely, change the regex used in the re.match call (the one building match
from raw) to allow either ":" or "." between hours and minutes (e.g., use [:.]
where the separator is currently optional colon), leaving the surrounding logic
(utc_pattern, the raw = utc_pattern.group(1), the raw[0] sign check) unchanged.

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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone static method added to CrmCog duplicates the logic already implemented in intake_form_processor.py and resume_profile_processor.py, and the shared _normalize_timezone module-level function in resume_extractor.py. The Discord bot already imports from the shared package (from five08.resume_extractor import ...) — importing and reusing the shared _normalize_timezone function would eliminate this duplication.

Copilot uses AI. Check for mistakes.

def _build_inference_lookup_summary(
self, *, file_content: bytes, attempts: list[dict[str, Any]] | None
) -> str:
Expand DownExpand Up@@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent "None" from being written into CRM optional fields.

At Line [3430] and Line [3473], str(hints.get(...)).strip() converts missing values to the literal string "None", which then gets persisted to CRM.

💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` at line 3430, The code
converts optional CRM fields like description using str(hints.get("description",
"")).strip() which yields the literal "None" when the dict contains None; change
to explicitly guard against None (and other non-values) by doing something like:
val = hints.get("description"); description = "" if val is None else
str(val).strip(); apply the same pattern to the other optional fields that use
hints.get(...) at the block around lines 3473-3475 so that None is converted to
an empty string before persisting.

if not isinstance(emails, list):
emails = []
if not isinstance(github_usernames, list):
Expand All@@ -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,
}
Expand All@@ -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()
Expand Down
2 changes: 1 addition & 1 deletion apps/discord_bot/src/five08/discord_bot/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Kimai time tracking settings
kimai_base_url: str
Expand Down
6 changes: 3 additions & 3 deletions apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
openai_model: str="5o-mini"
resume_ai_model: str="5o-mini"
openai_model: str="gpt-4o-mini"
resume_ai_model: str="gpt-4o-mini"

Copilot uses AI. Check for mistakes.
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

Expand DownExpand Up@@ -157,7 +157,7 @@ def resolved_resume_ai_model(self) -> str:
if not candidate:
candidate = self.openai_model.strip()
if not candidate:
return "gpt-4o-mini"
return "5o-mini"

CopilotAIMar 3, 2026

Copy link

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.

Suggested change
return"5o-mini"
return"gpt-4o-mini"

Copilot uses AI. Check for mistakes.

# Keep explicit provider prefixes intact.
if "/" in candidate:
Expand Down
70 changes: 70 additions & 0 deletions apps/worker/src/five08/worker/crm/intake_form_processor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize new timezone and address_city form values before CRM mapping.

Line 326 currently routes these new keys through _normalize_text, so raw values can persist and also block normalized resume-derived values during merge (Line 361).

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_roles

Also applies to: 463-466

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 36 -
37, In _build_intake_updates, normalize the incoming form values for the keys
"address_city" and "timezone" (call self._normalize_text on those form fields)
and store the normalized values into the intake_updates mapping so the
normalized form values are used in subsequent merge logic; also ensure the same
change is applied to the other occurrence of those keys in the method (the
second mapping block) so resume-derived normalized values can correctly
override/merge with form input. Include references to the keys "address_city"
and "timezone", the method _build_intake_updates, and the normalizer method
_normalize_text when making the change.

"primary_role": "cRoles",
Comment on lines +36 to 38

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The address_city and timezone keys added to FIELD_MAP (lines 36–37) are processed by the generic _normalize_text branch in _build_intake_updates (line 336), which only strips whitespace. This means form-submitted city values like "New York, NY" will be stored verbatim rather than normalized to just "New York", and timezone values like "UTC+5" won't be converted to the canonical "UTC+05:00" format. The other code paths (resume extraction) correctly apply _normalize_city and _normalize_timezone, creating inconsistent data. The loop in _build_intake_updates should include explicit normalization cases for these two keys, similar to how github_username and primary_role receive special handling.

Copilot uses AI. Check for mistakes.
"availability": "cAvailableTimes",
"rate_range": "cRateRange",
Expand DownExpand Up@@ -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)
)
Expand All@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize resume-derived country before persisting addressCountry.

Line 467 uses _normalize_text only, so casing can drift (united states vs United States) even though other inferred fields are normalized.

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
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/crm/intake_form_processor.py` around lines 467
- 469, The addressCountry value is only run through _normalize_text which leaves
casing inconsistent; add a new helper _normalize_country(value) that calls
_normalize_text(value) and returns normalized.title() (or None) and then replace
the usage of _normalize_text(extracted_profile.address_country) with
_normalize_country(extracted_profile.address_country) when setting
updates.setdefault("addressCountry", ...); reference the new _normalize_country
function and the existing _normalize_text and extracted_profile.address_country
symbols when making the change.

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)
Expand DownExpand Up@@ -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

CopilotAIMar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new _normalize_timezone and _normalize_city methods added to IntakeFormProcessor are instance methods (implicitly using self) but don't reference self at all. They should be @staticmethod for consistency with similar pure utility methods in ResumeProfileProcessor (e.g., _normalize_country, _normalize_city, _normalize_timezone at lines 827–886 of resume_profile_processor.py).

Copilot uses AI. Check for mistakes.

def _normalize_github_username(self, value: object) -> str | None:
normalized = self._normalize_text(value)
if normalized is None:
Expand Down
Loading