This commit is contained in:
wyn
2026-06-08 11:01:17 +08:00
4 changed files with 537 additions and 149 deletions

5
.env
View File

@@ -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

View File

@@ -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');
}
}

View File

@@ -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);
}
}

View File

@@ -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 判断。'
. '要求精确、简洁13 个中文领域词或短短语,用顿号分隔;不要解释、不要英文。'
. '只输出 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';
}