299 lines
9.4 KiB
PHP
299 lines
9.4 KiB
PHP
<?php
|
|||
|
|
namespace app\common\service;
|
||
|
|
|
||
|
|
use think\Cache;
|
||
|
|
use think\Exception;
|
||
|
|
use app\common\traits\ServiceTrait;
|
||
|
|
|
||
|
|
class DemandMatchService
|
||
|
|
{
|
||
|
|
private $weights = [
|
||
|
|
'skill' => 0.6, // 技能匹配权重
|
||
|
|
'composite' => 0.2, // 综合评分权重
|
||
|
|
'experience' => 0.1, // 经验权重
|
||
|
|
'reputation' => 0.1 // 信誉权重
|
||
|
|
];
|
||
|
|
|
||
|
|
private $stopWords = [];
|
||
|
|
private $jieba;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 构造函数
|
||
|
|
* @param array $config 配置参数
|
||
|
|
*/
|
||
|
|
public function __construct($config = [])
|
||
|
|
{
|
||
|
|
// 初始化权重配置
|
||
|
|
if (isset($config['weights'])) {
|
||
|
|
$this->weights = array_merge($this->weights, $config['weights']);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 加载停用词表
|
||
|
|
$this->loadStopWords();
|
||
|
|
|
||
|
|
// 初始化分词工具(需要安装 jieba-php 扩展)
|
||
|
|
// $this->jieba = new \Fukuball\Jieba\Jieba();
|
||
|
|
// $this->jieba->init();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 主匹配方法
|
||
|
|
* @param array $demand 需求数据
|
||
|
|
* @param array $programmers 程序员列表
|
||
|
|
* @param int $topN 返回前N名
|
||
|
|
* @return array 匹配结果
|
||
|
|
*/
|
||
|
|
public function match($demand, $programmers, $topN = 5)
|
||
|
|
{
|
||
|
|
// 1. 需求分析
|
||
|
|
$demandProfile = $this->analyzeDemand($demand);
|
||
|
|
|
||
|
|
// 2. 为每个程序员计算匹配度
|
||
|
|
$matches = [];
|
||
|
|
foreach ($programmers as $programmer) {
|
||
|
|
// 构建程序员画像
|
||
|
|
$programmerProfile = $this->buildProgrammerProfile($programmer);
|
||
|
|
|
||
|
|
// 计算匹配度
|
||
|
|
$matchScore = $this->calculateMatchScore($demandProfile, $programmerProfile);
|
||
|
|
|
||
|
|
$matches[] = [
|
||
|
|
'programmer' => $programmer,
|
||
|
|
'score' => $matchScore,
|
||
|
|
'details' => $this->getMatchDetails($demandProfile, $programmerProfile)
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. 按匹配度排序并返回前N名
|
||
|
|
usort($matches, function($a, $b) {
|
||
|
|
return $b['score'] <=> $a['score'];
|
||
|
|
});
|
||
|
|
|
||
|
|
return array_slice($matches, 0, $topN);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 需求分析
|
||
|
|
* @param array $demand 需求数据
|
||
|
|
* @return array 需求画像
|
||
|
|
*/
|
||
|
|
private function analyzeDemand($demand)
|
||
|
|
{
|
||
|
|
$keywords = [];
|
||
|
|
|
||
|
|
// 组合标题和描述进行分词
|
||
|
|
$text = $demand['title'] . ' ' . ($demand['description'] ?? '');
|
||
|
|
|
||
|
|
// 使用分词工具(这里用简单空格分词模拟,实际使用jieba)
|
||
|
|
// $words = $this->jieba->cut($text);
|
||
|
|
$words = $this->simpleChineseSegment($text);
|
||
|
|
|
||
|
|
// 去停用词和统计词频
|
||
|
|
$wordFreq = [];
|
||
|
|
foreach ($words as $word) {
|
||
|
|
$word = trim($word);
|
||
|
|
if (empty($word) || in_array($word, $this->stopWords) || mb_strlen($word) < 2) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
$wordFreq[$word] = ($wordFreq[$word] ?? 0) + 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 按词频排序,取前10个作为关键词
|
||
|
|
arsort($wordFreq);
|
||
|
|
$topKeywords = array_slice(array_keys($wordFreq), 0, 10);
|
||
|
|
|
||
|
|
// 合并客户指定的技能要求
|
||
|
|
$skillTags = array_unique(array_merge(
|
||
|
|
$topKeywords,
|
||
|
|
$demand['required_skills'] ?? []
|
||
|
|
));
|
||
|
|
|
||
|
|
return [
|
||
|
|
'keywords' => $skillTags,
|
||
|
|
'original_text' => $text,
|
||
|
|
'word_frequency' => $wordFreq
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 构建程序员画像
|
||
|
|
* @param array $programmer 程序员数据
|
||
|
|
* @return array 程序员画像
|
||
|
|
*/
|
||
|
|
private function buildProgrammerProfile($programmer)
|
||
|
|
{
|
||
|
|
// 计算综合评分
|
||
|
|
$compositeScore = $this->calculateCompositeScore($programmer);
|
||
|
|
|
||
|
|
return [
|
||
|
|
'skill_tags' => $programmer['skills'] ?? [],
|
||
|
|
'experience_years' => $programmer['experience_years'] ?? 0,
|
||
|
|
'composite_score' => $compositeScore,
|
||
|
|
'completion_rate' => $programmer['completion_rate'] ?? 0,
|
||
|
|
'avg_rating' => $programmer['avg_rating'] ?? 0,
|
||
|
|
'reputation_score' => $programmer['reputation_score'] ?? 0,
|
||
|
|
'project_count' => $programmer['project_count'] ?? 0
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 计算程序员综合评分
|
||
|
|
* @param array $programmer 程序员数据
|
||
|
|
* @return float 综合评分
|
||
|
|
*/
|
||
|
|
private function calculateCompositeScore($programmer)
|
||
|
|
{
|
||
|
|
$weights = [
|
||
|
|
'experience' => 0.3,
|
||
|
|
'completion_rate' => 0.25,
|
||
|
|
'avg_rating' => 0.25,
|
||
|
|
'reputation' => 0.2
|
||
|
|
];
|
||
|
|
|
||
|
|
$score = 0;
|
||
|
|
$score += ($programmer['experience_years'] ?? 0) / 10 * $weights['experience']; // 假设10年为满分
|
||
|
|
$score += ($programmer['completion_rate'] ?? 0) * $weights['completion_rate'];
|
||
|
|
$score += (($programmer['avg_rating'] ?? 0) / 5) * $weights['avg_rating']; // 5分制归一化
|
||
|
|
$score += ($programmer['reputation_score'] ?? 0) * $weights['reputation'];
|
||
|
|
|
||
|
|
return min(1.0, $score); // 确保不超过1.0
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 计算匹配度
|
||
|
|
* @param array $demandProfile 需求画像
|
||
|
|
* @param array $programmerProfile 程序员画像
|
||
|
|
* @return float 匹配度分数
|
||
|
|
*/
|
||
|
|
private function calculateMatchScore($demandProfile, $programmerProfile)
|
||
|
|
{
|
||
|
|
// 1. 技能相似度计算
|
||
|
|
$skillSimilarity = $this->jaccardSimilarity(
|
||
|
|
$demandProfile['keywords'],
|
||
|
|
$programmerProfile['skill_tags']
|
||
|
|
);
|
||
|
|
|
||
|
|
// 2. 经验系数计算
|
||
|
|
$experienceFactor = $this->normalizeExperience($programmerProfile['experience_years']);
|
||
|
|
|
||
|
|
// 3. 综合评分
|
||
|
|
$compositeScore = $programmerProfile['composite_score'];
|
||
|
|
|
||
|
|
// 4. 信誉系数
|
||
|
|
$reputationFactor = $programmerProfile['reputation_score'];
|
||
|
|
|
||
|
|
// 5. 加权融合
|
||
|
|
return $this->combineFactorsWithWeights([
|
||
|
|
'skill' => $skillSimilarity,
|
||
|
|
'composite' => $compositeScore,
|
||
|
|
'experience' => $experienceFactor,
|
||
|
|
'reputation' => $reputationFactor
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Jaccard相似度算法
|
||
|
|
* @param array $arrayA 数组A
|
||
|
|
* @param array $arrayB 数组B
|
||
|
|
* @return float 相似度
|
||
|
|
*/
|
||
|
|
private function jaccardSimilarity($arrayA, $arrayB)
|
||
|
|
{
|
||
|
|
if (empty($arrayA) || empty($arrayB)) {
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
$intersection = count(array_intersect($arrayA, $arrayB));
|
||
|
|
$union = count(array_unique(array_merge($arrayA, $arrayB)));
|
||
|
|
|
||
|
|
return $union > 0 ? $intersection / $union : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 经验系数归一化
|
||
|
|
* @param int $experienceYears 经验年数
|
||
|
|
* @return float 归一化后的经验系数
|
||
|
|
*/
|
||
|
|
private function normalizeExperience($experienceYears)
|
||
|
|
{
|
||
|
|
// 使用对数函数归一化,防止经验年数过高时权重过大
|
||
|
|
return min(1.0, log($experienceYears + 1) / log(11)); // 10年经验为1.0
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 加权融合各个因素
|
||
|
|
* @param array $factors 各因素得分
|
||
|
|
* @return float 最终得分
|
||
|
|
*/
|
||
|
|
private function combineFactorsWithWeights($factors)
|
||
|
|
{
|
||
|
|
$totalScore = 0;
|
||
|
|
foreach ($factors as $factor => $score) {
|
||
|
|
$totalScore += $score * ($this->weights[$factor] ?? 0);
|
||
|
|
}
|
||
|
|
return $totalScore;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 获取匹配详情
|
||
|
|
* @param array $demandProfile 需求画像
|
||
|
|
* @param array $programmerProfile 程序员画像
|
||
|
|
* @return array 匹配详情
|
||
|
|
*/
|
||
|
|
private function getMatchDetails($demandProfile, $programmerProfile)
|
||
|
|
{
|
||
|
|
$skillSimilarity = $this->jaccardSimilarity(
|
||
|
|
$demandProfile['keywords'],
|
||
|
|
$programmerProfile['skill_tags']
|
||
|
|
);
|
||
|
|
|
||
|
|
return [
|
||
|
|
'skill_similarity' => $skillSimilarity,
|
||
|
|
'composite_score' => $programmerProfile['composite_score'],
|
||
|
|
'experience_factor' => $this->normalizeExperience($programmerProfile['experience_years']),
|
||
|
|
'reputation_factor' => $programmerProfile['reputation_score'],
|
||
|
|
'matched_skills' => array_intersect($demandProfile['keywords'], $programmerProfile['skill_tags'])
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 简单中文分词(用于演示,实际应使用jieba)
|
||
|
|
* @param string $text 文本
|
||
|
|
* @return array 分词结果
|
||
|
|
*/
|
||
|
|
private function simpleChineseSegment($text)
|
||
|
|
{
|
||
|
|
// 移除标点符号,按空格和常见分隔符分割
|
||
|
|
$text = preg_replace('/[^\w\x{4e00}-\x{9fa5}]/u', ' ', $text);
|
||
|
|
return preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 加载停用词表
|
||
|
|
*/
|
||
|
|
private function loadStopWords()
|
||
|
|
{
|
||
|
|
// 这里应该从文件加载停用词表,这里用常见停用词示例
|
||
|
|
$this->stopWords = [
|
||
|
|
'的', '了', '在', '是', '我', '有', '和', '就', '不', '人', '都', '一', '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有', '看', '好', '自己', '这', '那', '他', '她', '它', '我们', '你们', '他们', '这个', '那个', '这些', '那些', '什么', '怎么', '为什么', '如何', '可以', '应该', '需要', '因为', '所以', '但是', '然后', '如果', '虽然', '尽管', '为了', '通过', '根据', '按照', '关于', '对于', '由于'
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 设置权重配置
|
||
|
|
* @param array $weights 权重配置
|
||
|
|
*/
|
||
|
|
public function setWeights($weights)
|
||
|
|
{
|
||
|
|
$this->weights = array_merge($this->weights, $weights);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 获取当前权重配置
|
||
|
|
* @return array 权重配置
|
||
|
|
*/
|
||
|
|
public function getWeights()
|
||
|
|
{
|
||
|
|
return $this->weights;
|
||
|
|
}
|
||
|
|
}
|