logFile = ROOT_PATH . 'runtime' . DS . 'expert_field_ai.log'; try { $this->ensureSchema(); } catch (\Throwable $e) { $this->log('[ExpertFieldAi] ensureSchema fail: ' . $e->getMessage()); } } // ===================== 链式队列 ===================== /** * 启动链式处理(关联 + AI,主入口)。 */ 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->findNextPendingExpertId($afterExpertId, $force); if ($expertId <= 0) { $this->log('[ExpertFieldAi] chain finished after expert_id=' . $afterExpertId); return false; } $data = [ 'expert_id' => $expertId, 'queue' => $queue, 'force' => $force ? 1 : 0, ]; $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; } /** @deprecated */ public function enqueueNextLink($delay = 1, $queue = '', $afterExpertId = 0, $force = false) { return $this->enqueueNext($delay, $queue, $afterExpertId, $force); } // ===================== 主流程 ===================== /** * 处理单个 expert:先关联 user,失败则 AI 总结。 */ public function processExpert($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'] ?? ''), ]; } $linkResult = $this->tryLinkFromUser($expertId, $expert, $force); if (!empty($linkResult['linked'])) { return array_merge(['ok' => true, 'method' => 'user_link'], $linkResult); } if (!$this->isEligible($expertId, $expert)) { $this->updateFieldAi($expertId, '', self::STATUS_INSUFFICIENT, '', 'insufficient papers/affiliation'); return ['ok' => true, 'insufficient' => true, 'method' => 'ai']; } 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, '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()]; } } 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; $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, ]; } 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))); // t_expert.country 已存国家英文名,无需再查 country 表 $countryName = trim((string)($expert['country'] ?? '')); if ($countryName === '') { $countryId = intval($expert['country_id'] ?? 0); if ($countryId > 0) { $row = Db::name('country')->where('country_id', $countryId)->find(); if ($row) { $countryName = (string)($row['en_name'] ?? ($row['zh_name'] ?? '')); } } } 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); $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, '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, ]; } // ===================== LLM ===================== private function summarizeWithLlm(array $context) { $payloadJson = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $systemPrompt = '你是学术领域分类助手。根据专家的单位、论文标题与 PubMed 检索上下文,用简体中文总结该专家最主要的研究领域。' . '注意:search_keywords 只是检索词,不可直接当作领域结论,应结合 paper 标题与 affiliation 判断。' . '要求:精确、简洁,1~3 个中文领域词或短短语,用顿号分隔;不要解释、不要英文。' . '只输出 JSON:{"field_ai":"..."}。'; $userPrompt = "请根据以下 JSON 资料总结该专家的主要研究领域:\n" . $payloadJson; // 按上下文长度动态选模型(小: base.model_url1 / 大: base.model_url) $svc = new LocalModelService(); $res = $svc->chat([ ['role' => 'system', 'content' => $systemPrompt], ['role' => 'user', 'content' => $userPrompt], ], ['temperature' => 0.2]); if (empty($res['ok'])) { throw new Exception('LLM error: ' . (string)($res['error'] ?? 'unknown')); } $this->log('[ExpertFieldAi] llm tier=' . ($res['tier'] ?? '') . ' ctx_len=' . ($res['context_len'] ?? 0) . ' url=' . ($res['url'] ?? '')); $content = trim((string)($res['content'] ?? '')); $fieldAi = $this->parseFieldAiFromContent($content); if ($fieldAi === '' && $content !== '') { $fieldAi = $this->cleanFieldAiText($content); } return $fieldAi; } 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); 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) ->whereOr('field_ai_status', self::STATUS_NO_USER_LINK); }); } $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) { $this->ensureSchema(); $data = [ 'field_ai' => mb_substr(trim((string)$fieldAi), 0, 512), 'field_ai_status' => intval($status), 'field_ai_utime' => time(), ]; if ($this->hasColumn('field_ai_source')) { $data['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); } } /** * 自动补全 t_expert 上缺失的 field_ai 字段(可重复执行)。 */ public function ensureSchema() { if (self::$schemaReady === true) { return; } $table = config('database.prefix') . 'expert'; $columns = Db::query('SHOW COLUMNS FROM `' . $table . '`'); $existing = []; foreach ($columns as $col) { $existing[$col['Field']] = true; } $alters = []; if (!isset($existing['field_ai'])) { $alters[] = "ADD COLUMN `field_ai` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'AI总结的主要研究领域(中文)' AFTER `affiliation`"; $existing['field_ai'] = true; } if (!isset($existing['field_ai_status'])) { $alters[] = "ADD COLUMN `field_ai_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待处理 1已生成 2资料不足 3失败 4无user待AI' AFTER `field_ai`"; $existing['field_ai_status'] = true; } if (!isset($existing['field_ai_utime'])) { $alters[] = "ADD COLUMN `field_ai_utime` INT NOT NULL DEFAULT 0 COMMENT 'field_ai更新时间' AFTER `field_ai_status`"; $existing['field_ai_utime'] = true; } if (!isset($existing['field_ai_source'])) { $alters[] = "ADD COLUMN `field_ai_source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源: user_link / ai' AFTER `field_ai_utime`"; $existing['field_ai_source'] = true; } if (!empty($alters)) { Db::execute('ALTER TABLE `' . $table . '` ' . implode(', ', $alters)); $this->log('[ExpertFieldAi] schema patched: ' . implode('; ', $alters)); } self::$schemaReady = true; } private function hasColumn($column) { $this->ensureSchema(); $table = config('database.prefix') . 'expert'; $columns = Db::query('SHOW COLUMNS FROM `' . $table . '` LIKE \'' . addslashes($column) . '\''); return !empty($columns); } 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); } }