"""Injectable, grounded skill suggestions for the enrichment skills card.""" from __future__ import annotations from typing import Any, Protocol from pydantic import Field from .enrichment_modules import skill_suggestions from .llm_services import OpenAICompatibleStructuredClient, StrictSchema from .settings import Settings class SkillSuggester(Protocol): """Suggest skills from the user's target role and already-entered resume facts.""" def suggest(self, profile: dict[str, Any]) -> list[str]: ... class RuleBasedSkillSuggester: def suggest(self, profile: dict[str, Any]) -> list[str]: return skill_suggestions(profile.get("target_position"), profile) class SkillSuggestionOutput(StrictSchema): skills: list[str] = Field(max_length=8) class OpenAISkillSuggester: def __init__( self, completion: OpenAICompatibleStructuredClient, fallback: SkillSuggester ) -> None: self.completion = completion self.fallback = fallback def suggest(self, profile: dict[str, Any]) -> list[str]: fallback = self.fallback.suggest(profile) try: output = self.completion.complete( schema=SkillSuggestionOutput, schema_name="skill_suggestions", system_prompt=( "你是中文求职简历助手。根据目标岗位和用户已经填写的经历事实推荐技能标签。" "只输出适合技能卡的简短技能名称,不要写句子、等级、熟练度或虚构项目成果。" "可以补充目标岗位常见但用户尚未填写的技能,作为待学习/待确认建议;" "不要把公司、学校、课程或奖项名称当作技能。" ), payload={ "target_position": profile.get("target_position"), "facts": _skill_facts(profile), "existing_skills": (profile.get("tags") or {}).get("skills") or [], }, ) except Exception: return fallback return _merge_suggestions(output.skills, fallback, profile) def build_skill_suggester( settings: Settings, client: Any | None = None ) -> SkillSuggester: rules = RuleBasedSkillSuggester() if not settings.use_openai: return rules return OpenAISkillSuggester(OpenAICompatibleStructuredClient(settings, client), rules) def _skill_facts(profile: dict[str, Any]) -> list[dict[str, Any]]: facts: list[dict[str, Any]] = [] entries: list[Any] = [profile.get("anchor")] entries.extend(profile.get("experiences") or []) for records in (profile.get("records") or {}).values(): entries.extend(records or []) for entry in entries: if not isinstance(entry, dict): continue description = str(entry.get("description") or "").strip() highlights = [str(item).strip() for item in entry.get("highlights") or [] if str(item).strip()] if description or highlights: facts.append( { "record_type": entry.get("record_type"), "description": description or None, "highlights": highlights, } ) return facts def _merge_suggestions( proposed: list[str], fallback: list[str], profile: dict[str, Any] ) -> list[str]: existing = { str(skill).strip().casefold() for skill in ((profile.get("tags") or {}).get("skills") or []) if str(skill).strip() } result: list[str] = [] seen: set[str] = set() for skill in [*proposed, *fallback]: normalized = str(skill).strip() key = normalized.casefold() if normalized and len(normalized) <= 32 and key not in existing and key not in seen: result.append(normalized) seen.add(key) return result[:8]