Files
resume-agent/backend/app/skill_suggester.py
T

106 lines
3.8 KiB
Python

"""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]