diff --git a/application/api/controller/ExpertFieldAi.php b/application/api/controller/ExpertFieldAi.php new file mode 100644 index 00000000..4ad58f33 --- /dev/null +++ b/application/api/controller/ExpertFieldAi.php @@ -0,0 +1,161 @@ +request->param('force', 0)) === 1; + $delay = max(0, intval($this->request->param('delay', 1))); + + $svc = new ExpertFieldAiService(); + $started = $svc->startLinkChain($force, $delay); + + return jsonSuccess([ + 'started' => $started, + 'queue' => ExpertFieldAiService::QUEUE_NAME, + 'force' => $force, + 'msg' => $started ? 'link chain enqueued' : 'no pending experts', + ]); + } + + /** + * 同步关联单个 expert + */ + public function linkOne() + { + $expertId = intval($this->request->param('expert_id', 0)); + $force = intval($this->request->param('force', 0)) === 1; + if ($expertId <= 0) { + return jsonError('expert_id required'); + } + + $svc = new ExpertFieldAiService(); + $result = $svc->linkFromUser($expertId, $force); + if (empty($result['ok'])) { + return jsonError(isset($result['error']) ? $result['error'] : 'failed'); + } + return jsonSuccess($result); + } + + /** + * 同步批量关联 + * expert_ids: 逗号分隔,或传 limit 扫描待处理前 N 条 + */ + public function linkBatch() + { + $force = intval($this->request->param('force', 0)) === 1; + $idsRaw = trim((string)$this->request->param('expert_ids', '')); + $limit = min(max(intval($this->request->param('limit', 0)), 0), 200); + + $ids = []; + if ($idsRaw !== '') { + $ids = array_filter(array_map('intval', explode(',', $idsRaw))); + } elseif ($limit > 0) { + $ids = Db::name('expert') + ->where('state', '<>', 5) + ->where(function ($q) { + $q->where('field_ai_status', ExpertFieldAiService::STATUS_PENDING) + ->whereOr('field_ai_status', ExpertFieldAiService::STATUS_FAILED); + }) + ->order('expert_id asc') + ->limit($limit) + ->column('expert_id'); + } + + if (empty($ids)) { + return jsonError('expert_ids 或 limit 必填'); + } + + $svc = new ExpertFieldAiService(); + $result = $svc->batchLinkFromUser($ids, $force); + return jsonSuccess($result); + } + + /** + * user 更新 field_ai 后,同步到同邮箱 expert + */ + public function syncByUser() + { + $userId = intval($this->request->param('user_id', 0)); + $force = intval($this->request->param('force', 0)) === 1; + if ($userId <= 0) { + return jsonError('user_id required'); + } + + $svc = new ExpertFieldAiService(); + $result = $svc->syncExpertsByUserId($userId, $force); + if (empty($result['ok'])) { + return jsonError(isset($result['error']) ? $result['error'] : 'failed'); + } + return jsonSuccess($result); + } + + /** + * 预览是否可关联 + */ + public function preview() + { + $expertId = intval($this->request->param('expert_id', 0)); + if ($expertId <= 0) { + return jsonError('expert_id required'); + } + + $svc = new ExpertFieldAiService(); + $result = $svc->previewLink($expertId); + if (empty($result['ok'])) { + return jsonError(isset($result['error']) ? $result['error'] : 'failed'); + } + + $result['field_ai_status_text'] = $svc->statusLabel(intval($result['expert_field_ai_status'])); + return jsonSuccess($result); + } + + /** + * 统计 field_ai 覆盖 + */ + public function statistics() + { + $total = Db::name('expert')->where('state', '<>', 5)->count(); + $done = Db::name('expert')->where('state', '<>', 5)->where('field_ai_status', ExpertFieldAiService::STATUS_DONE)->count(); + $userLink = Db::name('expert') + ->where('state', '<>', 5) + ->where('field_ai_source', ExpertFieldAiService::SOURCE_USER_LINK) + ->count(); + $noUserLink = Db::name('expert') + ->where('state', '<>', 5) + ->where('field_ai_status', ExpertFieldAiService::STATUS_NO_USER_LINK) + ->count(); + $pending = Db::name('expert') + ->where('state', '<>', 5) + ->where('field_ai_status', ExpertFieldAiService::STATUS_PENDING) + ->count(); + + return jsonSuccess([ + 'total' => $total, + 'done' => $done, + 'user_link' => $userLink, + 'no_user_link' => $noUserLink, + 'pending' => $pending, + 'coverage_rate' => $total > 0 ? round($done / $total * 100, 2) . '%' : '0%', + ]); + } +} diff --git a/application/api/controller/ExpertManage.php b/application/api/controller/ExpertManage.php index 87992b2c..bd3a0fea 100644 --- a/application/api/controller/ExpertManage.php +++ b/application/api/controller/ExpertManage.php @@ -44,17 +44,22 @@ class ExpertManage extends Base $query = Db::name('expert')->alias('e'); $countQuery = Db::name('expert')->alias('e'); - $needJoin = ($field !== ''); - if ($needJoin) { - $query->join('t_expert_field ef', 'ef.expert_id = e.expert_id AND ef.state = 0', 'inner'); - $countQuery->join('t_expert_field ef', 'ef.expert_id = e.expert_id AND ef.state = 0', 'inner'); - if ($field !== '') { - $query->where('ef.field', 'like', '%' . $field . '%'); - $countQuery->where('ef.field', 'like', '%' . $field . '%'); - } - $query->group('e.expert_id'); - $countQuery->group('e.expert_id'); + if ($field !== '') { + $fieldExpertIds = Db::name('expert_field') + ->where('state', 0) + ->where('field', 'like', '%' . $field . '%') + ->column('expert_id'); + $fieldExpertIds = array_values(array_unique(array_filter(array_map('intval', $fieldExpertIds)))); + + $fieldWhere = function ($q) use ($field, $fieldExpertIds) { + $q->where('e.field_ai', 'like', '%' . $field . '%'); + if (!empty($fieldExpertIds)) { + $q->whereOr('e.expert_id', 'in', $fieldExpertIds); + } + }; + $query->where($fieldWhere); + $countQuery->where($fieldWhere); } if ($state !== '-1' && $state !== '') { @@ -62,8 +67,8 @@ class ExpertManage extends Base $countQuery->where('e.state', intval($state)); } if ($keyword !== '') { - $query->where('e.name|e.email|e.affiliation', 'like', '%' . $keyword . '%'); - $countQuery->where('e.name|e.email|e.affiliation', 'like', '%' . $keyword . '%'); + $query->where('e.name|e.email|e.affiliation|e.field_ai', 'like', '%' . $keyword . '%'); + $countQuery->where('e.name|e.email|e.affiliation|e.field_ai', 'like', '%' . $keyword . '%'); } if ($source !== '') { $query->where('e.source', $source); @@ -72,7 +77,7 @@ class ExpertManage extends Base // $countQuery = clone $query; // $total = $countQuery->distinct('e.expert_id')->count(); - $total = $needJoin ? count($countQuery->group('e.expert_id')->column('e.expert_id')) : $countQuery->count(); + $total = $countQuery->count(); $list = $query ->field('e.*') diff --git a/application/api/job/ExpertFieldAiFill.php b/application/api/job/ExpertFieldAiFill.php new file mode 100644 index 00000000..d86afe12 --- /dev/null +++ b/application/api/job/ExpertFieldAiFill.php @@ -0,0 +1,38 @@ + 0 && $mode === 'link') { + $svc->linkFromUser($expertId, $force); + } + + $job->delete(); + + $delay = max(0, (int)(isset($data['delay']) ? $data['delay'] : 1)); + $svc->enqueueNextLink($delay, $queue, $expertId, $force); + } +} diff --git a/application/common/ExpertFieldAiService.php b/application/common/ExpertFieldAiService.php new file mode 100644 index 00000000..eae654d0 --- /dev/null +++ b/application/common/ExpertFieldAiService.php @@ -0,0 +1,321 @@ +logFile = ROOT_PATH . 'runtime' . DS . 'expert_field_ai.log'; + } + + /** + * 启动链式关联(从 expert_id=0 之后找下一位待处理专家)。 + */ + public function startLinkChain($force = false, $delay = 1, $queue = '') + { + return $this->enqueueNextLink($delay, $queue, 0, $force); + } + + /** + * 链式:处理 expert_id > $afterExpertId 的下一位。 + */ + public function enqueueNextLink($delay = 1, $queue = '', $afterExpertId = 0, $force = false) + { + if ($queue === '') { + $queue = self::QUEUE_NAME; + } + $afterExpertId = intval($afterExpertId); + $expertId = $this->findNextLinkExpertId($afterExpertId, $force); + if ($expertId <= 0) { + $this->log('[ExpertFieldAi] link chain finished after expert_id=' . $afterExpertId); + return false; + } + + $data = [ + 'expert_id' => $expertId, + 'queue' => $queue, + 'force' => $force ? 1 : 0, + 'mode' => 'link', + ]; + $jobClass = 'app\\api\\job\\ExpertFieldAiFill@fire'; + if ($delay > 0) { + Queue::later($delay, $jobClass, $data, $queue); + } else { + Queue::push($jobClass, $data, $queue); + } + $this->log('[ExpertFieldAi] enqueued expert_id=' . $expertId . ' queue=' . $queue); + return true; + } + + /** + * 单个 expert:尝试从 user 邮箱关联 field_ai。 + * + * @return array{ok:bool, linked?:bool, skipped?:bool, field_ai?:string, user_id?:int, error?:string} + */ + public function linkFromUser($expertId, $force = false) + { + $expertId = intval($expertId); + if ($expertId <= 0) { + return ['ok' => false, 'error' => 'invalid expert_id']; + } + + $expert = Db::name('expert')->where('expert_id', $expertId)->find(); + if (!$expert) { + return ['ok' => false, 'error' => 'expert not found']; + } + + if (!$force + && intval($expert['field_ai_status']) === self::STATUS_DONE + && trim((string)$expert['field_ai']) !== '') { + return [ + 'ok' => true, + 'skipped' => true, + 'field_ai' => (string)$expert['field_ai'], + 'source' => (string)($expert['field_ai_source'] ?? ''), + ]; + } + + $email = strtolower(trim((string)($expert['email'] ?? ''))); + if ($email === '') { + $this->updateFieldAi($expertId, '', self::STATUS_NO_USER_LINK, '', 'no email'); + return ['ok' => true, 'linked' => false, 'reason' => 'empty email']; + } + + $user = Db::name('user') + ->where('email', $email) + ->where('state', 0) + ->field('user_id,email,realname') + ->find(); + + if (!$user) { + $this->updateFieldAi($expertId, '', self::STATUS_NO_USER_LINK, '', 'no matching user'); + return ['ok' => true, 'linked' => false, 'reason' => 'user not found']; + } + + $uri = Db::name('user_reviewer_info') + ->where('reviewer_id', intval($user['user_id'])) + ->where('state', 0) + ->find(); + + $fieldAi = $uri ? trim((string)($uri['field_ai'] ?? '')) : ''; + $userStatus = $uri ? intval($uri['field_ai_status']) : 0; + + if ($fieldAi === '' || $userStatus !== UserFieldAiService::STATUS_DONE) { + $this->updateFieldAi($expertId, '', self::STATUS_NO_USER_LINK, '', 'user field_ai not ready'); + return [ + 'ok' => true, + 'linked' => false, + 'user_id' => intval($user['user_id']), + 'reason' => 'user has no field_ai', + ]; + } + + $this->updateFieldAi($expertId, $fieldAi, self::STATUS_DONE, self::SOURCE_USER_LINK, 'linked from user_id=' . $user['user_id']); + return [ + 'ok' => true, + 'linked' => true, + 'field_ai' => $fieldAi, + 'user_id' => intval($user['user_id']), + 'source' => self::SOURCE_USER_LINK, + ]; + } + + /** + * 批量同步(同步执行,适合小批量调试)。 + */ + public function batchLinkFromUser(array $expertIds, $force = false) + { + $linked = 0; + $skipped = 0; + $noLink = 0; + $failed = 0; + $details = []; + + foreach ($expertIds as $expertId) { + $expertId = intval($expertId); + if ($expertId <= 0) { + continue; + } + $result = $this->linkFromUser($expertId, $force); + if (empty($result['ok'])) { + $failed++; + } elseif (!empty($result['skipped'])) { + $skipped++; + } elseif (!empty($result['linked'])) { + $linked++; + } else { + $noLink++; + } + $details[] = array_merge(['expert_id' => $expertId], $result); + } + + return [ + 'total' => count($details), + 'linked' => $linked, + 'skipped' => $skipped, + 'no_link' => $noLink, + 'failed' => $failed, + 'details' => $details, + ]; + } + + /** + * 预览:expert 是否可关联到 user.field_ai。 + */ + public function previewLink($expertId) + { + $expertId = intval($expertId); + $expert = Db::name('expert')->where('expert_id', $expertId)->find(); + if (!$expert) { + return ['ok' => false, 'error' => 'expert not found']; + } + + $email = strtolower(trim((string)($expert['email'] ?? ''))); + $user = null; + $uri = null; + if ($email !== '') { + $user = Db::name('user')->where('email', $email)->where('state', 0)->field('user_id,email,realname')->find(); + if ($user) { + $uri = Db::name('user_reviewer_info') + ->where('reviewer_id', intval($user['user_id'])) + ->where('state', 0) + ->find(); + } + } + + $canLink = $user && $uri + && trim((string)($uri['field_ai'] ?? '')) !== '' + && intval($uri['field_ai_status']) === UserFieldAiService::STATUS_DONE; + + return [ + 'ok' => true, + 'expert_id' => $expertId, + 'expert_email' => $email, + 'expert_field_ai' => (string)($expert['field_ai'] ?? ''), + 'expert_field_ai_status'=> intval($expert['field_ai_status'] ?? 0), + 'matched_user_id' => $user ? intval($user['user_id']) : 0, + 'matched_user_name' => $user ? (string)$user['realname'] : '', + 'user_field_ai' => $uri ? (string)($uri['field_ai'] ?? '') : '', + 'user_field_ai_status' => $uri ? intval($uri['field_ai_status']) : 0, + 'can_link' => $canLink, + ]; + } + + /** + * user 生成 field_ai 后,反向同步到同邮箱 expert(可选调用)。 + */ + public function syncExpertsByUserId($userId, $force = false) + { + $userId = intval($userId); + $user = Db::name('user')->where('user_id', $userId)->where('state', 0)->field('user_id,email')->find(); + if (!$user || trim((string)$user['email']) === '') { + return ['ok' => false, 'error' => 'user not found']; + } + + $email = strtolower(trim((string)$user['email'])); + $expertIds = Db::name('expert') + ->where('email', $email) + ->where('state', '<>', 5) + ->column('expert_id'); + + if (empty($expertIds)) { + return ['ok' => true, 'synced' => 0, 'msg' => 'no expert with same email']; + } + + return array_merge(['ok' => true], $this->batchLinkFromUser($expertIds, $force)); + } + + private function findNextLinkExpertId($afterExpertId, $force) + { + $batch = 50; + $cursor = intval($afterExpertId); + + while (true) { + $query = Db::name('expert') + ->where('expert_id', '>', $cursor) + ->where('state', '<>', 5); + + if (!$force) { + $query->where(function ($q) { + $q->where('field_ai_status', self::STATUS_PENDING) + ->whereOr('field_ai_status', self::STATUS_FAILED); + }); + } + + $ids = $query->order('expert_id asc')->limit($batch)->column('expert_id'); + if (empty($ids)) { + return 0; + } + + foreach ($ids as $expertId) { + $expertId = intval($expertId); + $cursor = $expertId; + + if (!$force) { + $row = Db::name('expert')->where('expert_id', $expertId)->field('field_ai,field_ai_status')->find(); + if ($row + && intval($row['field_ai_status']) === self::STATUS_DONE + && trim((string)$row['field_ai']) !== '') { + continue; + } + } + + return $expertId; + } + } + } + + private function updateFieldAi($expertId, $fieldAi, $status, $source, $note) + { + $data = [ + 'field_ai' => mb_substr(trim((string)$fieldAi), 0, 512), + 'field_ai_status' => intval($status), + 'field_ai_utime' => time(), + 'field_ai_source' => mb_substr(trim((string)$source), 0, 32), + ]; + Db::name('expert')->where('expert_id', intval($expertId))->update($data); + if ($note !== '') { + $this->log('[ExpertFieldAi] expert_id=' . $expertId . ' status=' . $status . ' note=' . $note); + } + } + + public function statusLabel($status) + { + $map = [ + self::STATUS_PENDING => 'pending', + self::STATUS_DONE => 'done', + self::STATUS_INSUFFICIENT => 'insufficient', + self::STATUS_FAILED => 'failed', + self::STATUS_NO_USER_LINK => 'no_user_link', + ]; + return isset($map[$status]) ? $map[$status] : 'unknown'; + } + + public function log($msg) + { + $line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL; + @file_put_contents($this->logFile, $line, FILE_APPEND); + } +} diff --git a/application/common/UserFieldAiService.php b/application/common/UserFieldAiService.php index 3bc023ec..20cee6bc 100644 --- a/application/common/UserFieldAiService.php +++ b/application/common/UserFieldAiService.php @@ -102,6 +102,7 @@ class UserFieldAiService throw new Exception('LLM returned empty field'); } $this->updateFieldAi($userId, $fieldAi, self::STATUS_DONE, ''); + $this->syncLinkedExperts($userId); return ['ok' => true, 'field_ai' => $fieldAi]; } catch (\Throwable $e) { $this->updateFieldAi($userId, '', self::STATUS_FAILED, mb_substr($e->getMessage(), 0, 500)); @@ -460,4 +461,17 @@ class UserFieldAiService $line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL; @file_put_contents($this->logFile, $line, FILE_APPEND); } + + /** + * user.field_ai 更新后,同步到同邮箱 expert(方案 C 关联)。 + */ + private function syncLinkedExperts($userId) + { + try { + $svc = new ExpertFieldAiService(); + $svc->syncExpertsByUserId(intval($userId), true); + } catch (\Throwable $e) { + $this->log('[FieldAi] sync expert fail user_id=' . $userId . ' ' . $e->getMessage()); + } + } } diff --git a/sql/add_field_ai_to_expert.sql b/sql/add_field_ai_to_expert.sql new file mode 100644 index 00000000..0f2b8547 --- /dev/null +++ b/sql/add_field_ai_to_expert.sql @@ -0,0 +1,6 @@ +-- Expert 主领域 AI 总结(方案 C:先邮箱关联 user.field_ai,后续可 AI 补全) +ALTER TABLE `t_expert` + ADD COLUMN `field_ai` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'AI/关联总结的主要研究领域(中文)' AFTER `affiliation`, + ADD COLUMN `field_ai_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待处理 1已生成 2资料不足 3失败 4无user关联待AI' AFTER `field_ai`, + ADD COLUMN `field_ai_utime` INT NOT NULL DEFAULT 0 COMMENT 'field_ai 更新时间' AFTER `field_ai_status`, + ADD COLUMN `field_ai_source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源: user_link / ai' AFTER `field_ai_utime`;