From 633ec028b04fca7d438d6ff192126df396a0a6d8 Mon Sep 17 00:00:00 2001 From: wangjinlei <751475802@qq.com> Date: Fri, 5 Jun 2026 15:50:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=80=BB=E7=BB=93expert=E9=A2=86=E5=9F=9F?= =?UTF-8?q?=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 5 + application/api/controller/ExpertFieldAi.php | 160 +++--- application/api/job/ExpertFieldAiFill.php | 15 +- application/common/ExpertFieldAiService.php | 506 ++++++++++++++++--- 4 files changed, 537 insertions(+), 149 deletions(-) diff --git a/.env b/.env index 01c668b8..b90922aa 100644 --- a/.env +++ b/.env @@ -29,6 +29,11 @@ model=DeepSeek-Coder-V2-Instruct ;chat_url=http://chat.taimed.cn/v1/chat/completions ;chat_model=DeepSeek-Coder-V2-Instruct +[expert_field_ai] +; Expert 库 field_ai AI 总结(留空则复用 user_field_ai / base.model_url) +;max_papers=8 +;timeout=90 + [promotion] PROMOTION_LLM_URL=http://chat.taimed.cn/v1/chat/completions PROMOTION_LLM_MODEL=DeepSeek-Coder-V2-Instruct diff --git a/application/api/controller/ExpertFieldAi.php b/application/api/controller/ExpertFieldAi.php index 4ad58f33..17cf1b67 100644 --- a/application/api/controller/ExpertFieldAi.php +++ b/application/api/controller/ExpertFieldAi.php @@ -6,39 +6,81 @@ use think\Db; use app\common\ExpertFieldAiService; /** - * Expert 领域总结(方案 C - 阶段1:邮箱关联 user.field_ai) + * Expert 领域 AI 总结(方案 C:少量 user 关联 + 主流程 AI) * - * POST startLinkChain 启动链式队列,批量关联 - * POST linkOne 同步关联单个 expert_id - * POST linkBatch 同步批量关联 expert_ids - * POST syncByUser user 有 field_ai 后,同步到同邮箱 expert - * GET preview 预览是否可关联 - * GET statistics 统计 field_ai 覆盖情况 + * POST startChain 启动链式队列(关联 + AI,主入口) + * POST processOne 同步处理单个 expert_id + * POST processBatch 同步批量处理 + * POST linkOne 仅 user 关联(调试) + * POST syncByUser user 有 field_ai 后同步到 expert + * GET preview 预览可关联 / 可 AI 总结 / 上下文 + * GET statistics 覆盖统计 */ class ExpertFieldAi extends Base { /** - * 启动链式关联队列 + * 启动链式处理(主入口) * Worker: php think queue:work --queue ExpertFieldAi */ - public function startLinkChain() + public function startChain() { $force = intval($this->request->param('force', 0)) === 1; $delay = max(0, intval($this->request->param('delay', 1))); $svc = new ExpertFieldAiService(); - $started = $svc->startLinkChain($force, $delay); + $started = $svc->startChain($force, $delay); return jsonSuccess([ 'started' => $started, 'queue' => ExpertFieldAiService::QUEUE_NAME, 'force' => $force, - 'msg' => $started ? 'link chain enqueued' : 'no pending experts', + 'msg' => $started ? 'chain enqueued' : 'no pending experts', ]); } + /** 兼容旧接口名 */ + public function startLinkChain() + { + return $this->startChain(); + } + /** - * 同步关联单个 expert + * 同步处理单个 expert(关联 + AI) + */ + public function processOne() + { + $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->processExpert($expertId, $force); + if (empty($result['ok'])) { + return jsonError(isset($result['error']) ? $result['error'] : 'failed'); + } + return jsonSuccess($result); + } + + /** + * 同步批量处理 + * expert_ids 逗号分隔,或 limit 扫描待处理前 N 条 + */ + public function processBatch() + { + $force = intval($this->request->param('force', 0)) === 1; + $ids = $this->resolveExpertIds(); + if (empty($ids)) { + return jsonError('expert_ids 或 limit 必填'); + } + + $svc = new ExpertFieldAiService(); + return jsonSuccess($svc->batchProcess($ids, $force)); + } + + /** + * 仅 user 关联(不 AI) */ public function linkOne() { @@ -56,43 +98,18 @@ class ExpertFieldAi extends Base 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'); - } - + $ids = $this->resolveExpertIds(true); if (empty($ids)) { return jsonError('expert_ids 或 limit 必填'); } $svc = new ExpertFieldAiService(); - $result = $svc->batchLinkFromUser($ids, $force); - return jsonSuccess($result); + return jsonSuccess($svc->batchLinkFromUser($ids, $force)); } - /** - * user 更新 field_ai 后,同步到同邮箱 expert - */ public function syncByUser() { $userId = intval($this->request->param('user_id', 0)); @@ -110,7 +127,7 @@ class ExpertFieldAi extends Base } /** - * 预览是否可关联 + * 预览:是否可 user 关联、是否可 AI、上下文摘要 */ public function preview() { @@ -120,7 +137,7 @@ class ExpertFieldAi extends Base } $svc = new ExpertFieldAiService(); - $result = $svc->previewLink($expertId); + $result = $svc->preview($expertId); if (empty($result['ok'])) { return jsonError(isset($result['error']) ? $result['error'] : 'failed'); } @@ -129,33 +146,58 @@ class ExpertFieldAi extends Base 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(); + $base = Db::name('expert')->where('state', '<>', 5); + $total = (clone $base)->count(); + $done = (clone $base)->where('field_ai_status', ExpertFieldAiService::STATUS_DONE)->count(); + $userLink = (clone $base)->where('field_ai_source', ExpertFieldAiService::SOURCE_USER_LINK)->count(); + $aiDone = (clone $base)->where('field_ai_source', ExpertFieldAiService::SOURCE_AI)->count(); + $pending = (clone $base)->where('field_ai_status', ExpertFieldAiService::STATUS_PENDING)->count(); + $noUserLink = (clone $base)->where('field_ai_status', ExpertFieldAiService::STATUS_NO_USER_LINK)->count(); + $insufficient = (clone $base)->where('field_ai_status', ExpertFieldAiService::STATUS_INSUFFICIENT)->count(); + $failed = (clone $base)->where('field_ai_status', ExpertFieldAiService::STATUS_FAILED)->count(); return jsonSuccess([ 'total' => $total, 'done' => $done, 'user_link' => $userLink, - 'no_user_link' => $noUserLink, + 'ai_done' => $aiDone, 'pending' => $pending, + 'no_user_link' => $noUserLink, + 'insufficient' => $insufficient, + 'failed' => $failed, 'coverage_rate' => $total > 0 ? round($done / $total * 100, 2) . '%' : '0%', ]); } + + private function resolveExpertIds($linkOnly = false) + { + $idsRaw = trim((string)$this->request->param('expert_ids', '')); + $limit = min(max(intval($this->request->param('limit', 0)), 0), 200); + + if ($idsRaw !== '') { + return array_filter(array_map('intval', explode(',', $idsRaw))); + } + + if ($limit <= 0) { + return []; + } + + $query = Db::name('expert')->where('state', '<>', 5); + if ($linkOnly) { + $query->where(function ($q) { + $q->where('field_ai_status', ExpertFieldAiService::STATUS_PENDING) + ->whereOr('field_ai_status', ExpertFieldAiService::STATUS_FAILED); + }); + } else { + $query->where(function ($q) { + $q->where('field_ai_status', ExpertFieldAiService::STATUS_PENDING) + ->whereOr('field_ai_status', ExpertFieldAiService::STATUS_FAILED) + ->whereOr('field_ai_status', ExpertFieldAiService::STATUS_NO_USER_LINK); + }); + } + + return $query->order('expert_id asc')->limit($limit)->column('expert_id'); + } } diff --git a/application/api/job/ExpertFieldAiFill.php b/application/api/job/ExpertFieldAiFill.php index d86afe12..cdc91d3d 100644 --- a/application/api/job/ExpertFieldAiFill.php +++ b/application/api/job/ExpertFieldAiFill.php @@ -6,13 +6,7 @@ use think\queue\Job; use app\common\ExpertFieldAiService; /** - * Expert field_ai 链式任务(阶段1:邮箱关联 user.field_ai) - * - * data: - * - expert_id - * - queue 队列名,默认 ExpertFieldAi - * - force 1=强制重算 - * - mode link(默认) + * Expert field_ai 链式任务:先尝试 user 关联,主流程 AI 总结 * * Worker: php think queue:work --queue ExpertFieldAi */ @@ -23,16 +17,15 @@ class ExpertFieldAiFill $expertId = isset($data['expert_id']) ? intval($data['expert_id']) : 0; $queue = isset($data['queue']) ? (string)$data['queue'] : ExpertFieldAiService::QUEUE_NAME; $force = !empty($data['force']); - $mode = isset($data['mode']) ? (string)$data['mode'] : 'link'; $svc = new ExpertFieldAiService(); - if ($expertId > 0 && $mode === 'link') { - $svc->linkFromUser($expertId, $force); + if ($expertId > 0) { + $svc->processExpert($expertId, $force); } $job->delete(); $delay = max(0, (int)(isset($data['delay']) ? $data['delay'] : 1)); - $svc->enqueueNextLink($delay, $queue, $expertId, $force); + $svc->enqueueNext($delay, $queue, $expertId, $force); } } diff --git a/application/common/ExpertFieldAiService.php b/application/common/ExpertFieldAiService.php index eae654d0..55cbc6ab 100644 --- a/application/common/ExpertFieldAiService.php +++ b/application/common/ExpertFieldAiService.php @@ -3,12 +3,14 @@ namespace app\common; use think\Db; +use think\Env; +use think\Exception; use think\Queue; /** * Expert 领域总结(方案 C) - * 阶段1:通过 email 关联 t_user / t_user_reviewer_info,复用 user.field_ai - * 阶段2(后续):对 field_ai_status=4 的记录走 LLM 总结 + * 1. 优先尝试 email 关联 user.field_ai(少量) + * 2. 主流程:根据 expert 论文/单位/检索词 AI 总结 field_ai */ class ExpertFieldAiService { @@ -30,26 +32,31 @@ class ExpertFieldAiService $this->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 的下一位。 + * 启动链式处理(关联 + AI,主入口)。 */ - public function enqueueNextLink($delay = 1, $queue = '', $afterExpertId = 0, $force = false) + public function startChain($force = false, $delay = 1, $queue = '') + { + return $this->enqueueNext($delay, $queue, 0, $force); + } + + /** @deprecated 兼容旧名 */ + public function startLinkChain($force = false, $delay = 1, $queue = '') + { + return $this->startChain($force, $delay, $queue); + } + + public function enqueueNext($delay = 1, $queue = '', $afterExpertId = 0, $force = false) { if ($queue === '') { $queue = self::QUEUE_NAME; } $afterExpertId = intval($afterExpertId); - $expertId = $this->findNextLinkExpertId($afterExpertId, $force); + $expertId = $this->findNextPendingExpertId($afterExpertId, $force); if ($expertId <= 0) { - $this->log('[ExpertFieldAi] link chain finished after expert_id=' . $afterExpertId); + $this->log('[ExpertFieldAi] chain finished after expert_id=' . $afterExpertId); return false; } @@ -57,7 +64,6 @@ class ExpertFieldAiService 'expert_id' => $expertId, 'queue' => $queue, 'force' => $force ? 1 : 0, - 'mode' => 'link', ]; $jobClass = 'app\\api\\job\\ExpertFieldAiFill@fire'; if ($delay > 0) { @@ -69,12 +75,18 @@ class ExpertFieldAiService return true; } + /** @deprecated */ + public function enqueueNextLink($delay = 1, $queue = '', $afterExpertId = 0, $force = false) + { + return $this->enqueueNext($delay, $queue, $afterExpertId, $force); + } + + // ===================== 主流程 ===================== + /** - * 单个 expert:尝试从 user 邮箱关联 field_ai。 - * - * @return array{ok:bool, linked?:bool, skipped?:bool, field_ai?:string, user_id?:int, error?:string} + * 处理单个 expert:先关联 user,失败则 AI 总结。 */ - public function linkFromUser($expertId, $force = false) + public function processExpert($expertId, $force = false) { $expertId = intval($expertId); if ($expertId <= 0) { @@ -97,54 +109,85 @@ class ExpertFieldAiService ]; } - $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']; + $linkResult = $this->tryLinkFromUser($expertId, $expert, $force); + if (!empty($linkResult['linked'])) { + return array_merge(['ok' => true, 'method' => 'user_link'], $linkResult); } - $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']; + if (!$this->isEligible($expertId, $expert)) { + $this->updateFieldAi($expertId, '', self::STATUS_INSUFFICIENT, '', 'insufficient papers/affiliation'); + return ['ok' => true, 'insufficient' => true, 'method' => 'ai']; } - $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'); + try { + $context = $this->buildContext($expertId, $expert); + $fieldAi = $this->summarizeWithLlm($context); + if ($fieldAi === '') { + throw new Exception('LLM returned empty field_ai'); + } + $this->updateFieldAi($expertId, $fieldAi, self::STATUS_DONE, self::SOURCE_AI, 'ai summarized'); return [ - 'ok' => true, - 'linked' => false, - 'user_id' => intval($user['user_id']), - 'reason' => 'user has no field_ai', + 'ok' => true, + 'method' => 'ai', + 'field_ai' => $fieldAi, + 'source' => self::SOURCE_AI, ]; + } catch (\Throwable $e) { + $this->updateFieldAi($expertId, '', self::STATUS_FAILED, '', mb_substr($e->getMessage(), 0, 500)); + $this->log('[ExpertFieldAi] expert_id=' . $expertId . ' ai fail: ' . $e->getMessage()); + return ['ok' => false, 'method' => 'ai', 'error' => $e->getMessage()]; } - - $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 batchProcess(array $expertIds, $force = false) + { + $stats = ['total' => 0, 'linked' => 0, 'ai' => 0, 'skipped' => 0, 'insufficient' => 0, 'failed' => 0]; + $details = []; + + foreach ($expertIds as $expertId) { + $expertId = intval($expertId); + if ($expertId <= 0) { + continue; + } + $result = $this->processExpert($expertId, $force); + $stats['total']++; + if (empty($result['ok'])) { + $stats['failed']++; + } elseif (!empty($result['skipped'])) { + $stats['skipped']++; + } elseif (!empty($result['linked']) || (isset($result['method']) && $result['method'] === 'user_link')) { + $stats['linked']++; + } elseif (!empty($result['insufficient'])) { + $stats['insufficient']++; + } elseif (isset($result['method']) && $result['method'] === 'ai') { + $stats['ai']++; + } + $details[] = array_merge(['expert_id' => $expertId], $result); + } + + return array_merge($stats, ['details' => $details]); + } + + // ===================== 关联 user(辅助) ===================== + /** - * 批量同步(同步执行,适合小批量调试)。 + * 仅做 user 关联(不触发 AI),供调试。 */ + public function linkFromUser($expertId, $force = false) + { + $expertId = intval($expertId); + $expert = Db::name('expert')->where('expert_id', $expertId)->find(); + if (!$expert) { + return ['ok' => false, 'error' => 'expert not found']; + } + + $result = $this->tryLinkFromUser($expertId, $expert, $force); + if (empty($result['linked']) && empty($result['skipped'])) { + $this->updateFieldAi($expertId, '', self::STATUS_NO_USER_LINK, '', 'link only: no user match'); + } + return array_merge(['ok' => true], $result); + } + public function batchLinkFromUser(array $expertIds, $force = false) { $linked = 0; @@ -181,9 +224,194 @@ class ExpertFieldAiService ]; } - /** - * 预览:expert 是否可关联到 user.field_ai。 - */ + private function tryLinkFromUser($expertId, $expert = null, $force = false) + { + if ($expert === null) { + $expert = Db::name('expert')->where('expert_id', intval($expertId))->find(); + } + if (!$expert) { + return ['linked' => false, 'reason' => 'expert not found']; + } + + if (!$force + && intval($expert['field_ai_status']) === self::STATUS_DONE + && trim((string)$expert['field_ai']) !== '') { + return [ + 'linked' => false, + 'skipped' => true, + 'field_ai' => (string)$expert['field_ai'], + 'source' => (string)($expert['field_ai_source'] ?? ''), + ]; + } + + $email = strtolower(trim((string)($expert['email'] ?? ''))); + if ($email === '') { + return ['linked' => false, 'reason' => 'empty email']; + } + + $user = Db::name('user')->where('email', $email)->where('state', 0)->field('user_id,email,realname')->find(); + if (!$user) { + return ['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'] ?? '')) : ''; + if ($fieldAi === '' || intval($uri['field_ai_status'] ?? 0) !== UserFieldAiService::STATUS_DONE) { + return ['linked' => false, 'user_id' => intval($user['user_id']), 'reason' => 'user has no field_ai']; + } + + $this->updateFieldAi(intval($expertId), $fieldAi, self::STATUS_DONE, self::SOURCE_USER_LINK, 'linked from user_id=' . $user['user_id']); + return [ + 'linked' => true, + 'field_ai' => $fieldAi, + 'user_id' => intval($user['user_id']), + 'source' => self::SOURCE_USER_LINK, + ]; + } + + 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)); + } + + // ===================== AI 上下文 ===================== + + public function isEligible($expertId, $expert = null) + { + if ($expert === null) { + $expert = Db::name('expert')->where('expert_id', intval($expertId))->find(); + } + if (!$expert) { + return false; + } + + if (trim((string)($expert['affiliation'] ?? '')) !== '') { + return true; + } + + $fieldRows = Db::name('expert_field') + ->where('expert_id', intval($expertId)) + ->where('state', 0) + ->field('field,paper_title,paper_journal') + ->select(); + + foreach ($fieldRows as $row) { + if (trim((string)($row['paper_title'] ?? '')) !== '') { + return true; + } + if (trim((string)($row['field'] ?? '')) !== '') { + return true; + } + } + + return false; + } + + public function buildContext($expertId, $expert = null) + { + if ($expert === null) { + $expert = Db::name('expert')->where('expert_id', intval($expertId))->find(); + } + + $fieldRows = Db::name('expert_field') + ->where('expert_id', intval($expertId)) + ->where('state', 0) + ->order('expert_field_id desc') + ->select(); + + $searchKeywords = []; + $papers = []; + $seenPaper = []; + + foreach ($fieldRows as $row) { + $kw = trim((string)($row['field'] ?? '')); + if ($kw !== '') { + $searchKeywords[] = $kw; + } + + $title = trim((string)($row['paper_title'] ?? '')); + if ($title === '') { + continue; + } + $paperKey = md5($title . '|' . ($row['paper_article_id'] ?? '')); + if (isset($seenPaper[$paperKey])) { + continue; + } + $seenPaper[$paperKey] = true; + $papers[] = [ + 'title' => mb_substr($title, 0, 300), + 'journal' => mb_substr(trim((string)($row['paper_journal'] ?? '')), 0, 120), + 'source' => trim((string)($row['source'] ?? '')), + 'keyword' => $kw, + ]; + } + + $maxPapers = max(1, min(15, (int)Env::get('expert_field_ai.max_papers', 8))); + $papers = array_slice($papers, 0, $maxPapers); + $searchKeywords = array_values(array_unique(array_filter($searchKeywords))); + + $countryName = ''; + $countryId = intval($expert['country_id'] ?? 0); + if ($countryId > 0) { + $countryName = (string)Db::name('country')->where('country_id', $countryId)->value('title'); + } + + return [ + 'expert' => [ + 'name' => trim((string)($expert['name'] ?? '')), + 'email' => trim((string)($expert['email'] ?? '')), + 'affiliation' => trim((string)($expert['affiliation'] ?? '')), + 'country' => $countryName, + 'source' => trim((string)($expert['source'] ?? '')), + ], + 'search_keywords' => $searchKeywords, + 'papers' => $papers, + 'note' => 'search_keywords 是 PubMed 检索词,不代表本人领域;请以论文标题与单位为准。', + ]; + } + + // ===================== 预览 / 统计 ===================== + + public function preview($expertId) + { + $expertId = intval($expertId); + $expert = Db::name('expert')->where('expert_id', $expertId)->find(); + if (!$expert) { + return ['ok' => false, 'error' => 'expert not found']; + } + + $linkPreview = $this->previewLink($expertId); + $eligible = $this->isEligible($expertId, $expert); + $context = $eligible ? $this->buildContext($expertId, $expert) : null; + + return [ + 'ok' => true, + 'expert_id' => $expertId, + 'expert_field_ai' => (string)($expert['field_ai'] ?? ''), + 'expert_field_ai_status' => intval($expert['field_ai_status'] ?? 0), + 'can_link_user' => !empty($linkPreview['can_link']), + 'link_preview' => $linkPreview, + 'eligible_for_ai' => $eligible, + 'context_preview' => $context, + ]; + } + public function previewLink($expertId) { $expertId = intval($expertId); @@ -213,8 +441,6 @@ class ExpertFieldAiService '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'] ?? '') : '', @@ -223,31 +449,152 @@ class ExpertFieldAiService ]; } - /** - * user 生成 field_ai 后,反向同步到同邮箱 expert(可选调用)。 - */ - public function syncExpertsByUserId($userId, $force = false) + // ===================== LLM ===================== + + private function summarizeWithLlm(array $context) { - $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']; + $url = $this->resolveLlmChatUrl(); + $model = $this->resolveLlmModel(); + $apiKey = trim((string)Env::get( + 'expert_field_ai.chat_api_key', + Env::get('user_field_ai.chat_api_key', Env::get('expert_country_chat_api_key', Env::get('citation_chat_api_key', ''))) + )); + + if ($url === '' || $model === '') { + throw new Exception('LLM not configured (set base.model_url / expert_field_ai.chat_model)'); } - $email = strtolower(trim((string)$user['email'])); - $expertIds = Db::name('expert') - ->where('email', $email) - ->where('state', '<>', 5) - ->column('expert_id'); + $payloadJson = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $messages = [ + [ + 'role' => 'system', + 'content' => '你是学术领域分类助手。根据专家的单位、论文标题与 PubMed 检索上下文,用简体中文总结该专家最主要的研究领域。' + . '注意:search_keywords 只是检索词,不可直接当作领域结论,应结合 paper 标题与 affiliation 判断。' + . '要求:精确、简洁,1~3 个中文领域词或短短语,用顿号分隔;不要解释、不要英文。' + . '只输出 JSON:{"field_ai":"..."}。', + ], + [ + 'role' => 'user', + 'content' => "请根据以下 JSON 资料总结该专家的主要研究领域:\n" . $payloadJson, + ], + ]; - if (empty($expertIds)) { - return ['ok' => true, 'synced' => 0, 'msg' => 'no expert with same email']; + $body = [ + 'model' => $model, + 'temperature' => 0.2, + 'messages' => $messages, + ]; + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 15, + CURLOPT_TIMEOUT => max(30, (int)Env::get('expert_field_ai.timeout', Env::get('user_field_ai.timeout', 90))), + CURLOPT_HTTPHEADER => array_filter([ + 'Content-Type: application/json', + $apiKey !== '' ? 'Authorization: Bearer ' . $apiKey : null, + ]), + ]); + $raw = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($raw === false) { + throw new Exception('LLM curl error: ' . $err); + } + if ($code < 200 || $code >= 300) { + throw new Exception('LLM HTTP ' . $code . ': ' . mb_substr((string)$raw, 0, 400)); } - return array_merge(['ok' => true], $this->batchLinkFromUser($expertIds, $force)); + $data = json_decode($raw, true); + $content = ''; + if (is_array($data) && isset($data['choices'][0]['message']['content'])) { + $content = trim((string)$data['choices'][0]['message']['content']); + } + + $fieldAi = $this->parseFieldAiFromContent($content); + if ($fieldAi === '' && $content !== '') { + $fieldAi = $this->cleanFieldAiText($content); + } + return $fieldAi; } - private function findNextLinkExpertId($afterExpertId, $force) + private function resolveLlmChatUrl() + { + $candidates = [ +// Env::get('expert_field_ai.chat_url', ''), +// Env::get('user_field_ai.chat_url', ''), + Env::get('base.model_url1', ''), + ]; + foreach ($candidates as $u) { + $u = trim((string)$u); + if ($u === '') { + continue; + } + if (stripos($u, 'chat/completions') !== false) { + return $u; + } + return rtrim($u, '/') . '/v1/chat/completions'; + } + return ''; + } + + private function resolveLlmModel() + { + $candidates = [ + Env::get('expert_field_ai.chat_model', ''), + Env::get('user_field_ai.chat_model', ''), + Env::get('base.model', ''), + Env::get('expert_country_chat_model', ''), + 'gpt-4.1', + ]; + foreach ($candidates as $m) { + $m = trim((string)$m); + if ($m !== '' && strtolower($m) !== 'your-model-name') { + return $m; + } + } + return ''; + } + + private function parseFieldAiFromContent($content) + { + $content = trim((string)$content); + if ($content === '') { + return ''; + } + $content = preg_replace('/^```[a-zA-Z]*\s*|```$/m', '', $content); + if (preg_match('/\{.*\}/s', $content, $m)) { + $obj = json_decode($m[0], true); + if (is_array($obj) && !empty($obj['field_ai'])) { + return $this->cleanFieldAiText((string)$obj['field_ai']); + } + } + $obj = json_decode($content, true); + if (is_array($obj) && !empty($obj['field_ai'])) { + return $this->cleanFieldAiText((string)$obj['field_ai']); + } + return ''; + } + + private function cleanFieldAiText($text) + { + $text = trim((string)$text); + $text = trim($text, "\"' \t\n\r"); + $text = preg_replace('/\s+/u', '', $text); + if (mb_strlen($text) > 200) { + $text = mb_substr($text, 0, 200); + } + return $text; + } + + // ===================== 内部工具 ===================== + + private function findNextPendingExpertId($afterExpertId, $force) { $batch = 50; $cursor = intval($afterExpertId); @@ -260,7 +607,8 @@ class ExpertFieldAiService if (!$force) { $query->where(function ($q) { $q->where('field_ai_status', self::STATUS_PENDING) - ->whereOr('field_ai_status', self::STATUS_FAILED); + ->whereOr('field_ai_status', self::STATUS_FAILED) + ->whereOr('field_ai_status', self::STATUS_NO_USER_LINK); }); } @@ -308,7 +656,7 @@ class ExpertFieldAiService self::STATUS_DONE => 'done', self::STATUS_INSUFFICIENT => 'insufficient', self::STATUS_FAILED => 'failed', - self::STATUS_NO_USER_LINK => 'no_user_link', + self::STATUS_NO_USER_LINK => 'no_user_link', ]; return isset($map[$status]) ? $map[$status] : 'unknown'; }