diff --git a/.env b/.env index d4136a1..94c0fc4 100644 --- a/.env +++ b/.env @@ -24,6 +24,7 @@ PROMOTION_LLM_URL=http://chat.taimed.cn/v1/chat/completions PROMOTION_LLM_MODEL=your-model-name PROMOTION_LLM_TIMEOUT=30 PROMOTION_LLM_FALLBACK="" +PROMOTION_LLM_ADVISED_FALLBACK="" [journal] ;官网服务器地址 diff --git a/application/common/PromotionLlmService.php b/application/common/PromotionLlmService.php index 24345c0..9651b4f 100644 --- a/application/common/PromotionLlmService.php +++ b/application/common/PromotionLlmService.php @@ -7,20 +7,24 @@ use think\Env; /** * 推广邮件 LLM 服务 * - * 作用:基于 expert 的代表作 (paper_title) + 期刊信息 (name / scope 等), - * 生成一段 2-3 句的个性化描述,用于邮件模板变量 {{llm_description}}。 + * 作用:基于 expert 的代表作 (paper_title)、expert 的研究领域、期刊信息 (name / scope / fields 等), + * 通过一次 LLM 调用同时生成两段邮件内容: + * - description 个性化赞美 + 邀稿段落(对应模板变量 {{ai_content_analysis}} / {{llm_description}}) + * - advised_topics "我们特别关注 X / Y / Z 领域" 段落(对应模板变量 {{ai_advised_topics}}) + * + * 单次调用返回 JSON,避免两次 LLM 请求导致的延迟翻倍;任一段失败可独立兜底。 * * 配置(.env 的 [promotion] 段): - * PROMOTION_LLM_URL chat/completions 接口地址 - * PROMOTION_LLM_MODEL 模型名 - * PROMOTION_LLM_TIMEOUT 超时时间(秒),默认 30 - * PROMOTION_LLM_FALLBACK 兜底描述(LLM 不可用 / 调用失败时使用) + * PROMOTION_LLM_URL chat/completions 接口地址 + * PROMOTION_LLM_MODEL 模型名 + * PROMOTION_LLM_TIMEOUT 超时时间(秒),默认 30 + * PROMOTION_LLM_FALLBACK description 兜底文案 + * PROMOTION_LLM_ADVISED_FALLBACK advised_topics 兜底文案 * - * 返回约定: - * ['status' => 1|2|3, 'text' => string] - * 1 = LLM 成功生成 - * 2 = LLM 调用失败,text = 兜底文案 - * 3 = 跳过(缺少代表作等前置条件),text = 兜底文案 + * 状态约定: + * 1 = LLM 成功生成 + * 2 = LLM 调用失败 / 解析失败,使用兜底 + * 3 = 前置条件不足(缺代表作、缺交集等),使用兜底 */ class PromotionLlmService { @@ -29,6 +33,7 @@ class PromotionLlmService private $timeout; private $apiKey; private $fallback; + private $advisedFallback; public function __construct() { @@ -38,27 +43,54 @@ class PromotionLlmService $this->apiKey = trim((string)Env::get('promotion.promotion_llm_api_key', '')); $this->fallback = trim((string)Env::get('promotion.promotion_llm_fallback', 'Your recent work aligns closely with the scope of our journal, and we would be honored to consider a contribution from you.')); + $this->advisedFallback = trim((string)Env::get('promotion.promotion_llm_advised_fallback', + 'We are especially interested in the research directions that align with your expertise, and warmly welcome your future submissions in these areas.')); } /** - * 生成个性化描述 + * 一次 LLM 调用生成邮件两段内容。 * - * @param array $expert 至少包含 name / representative_work_title / fields / affiliation - * @param array $journal 至少包含 title (name) / aims (可选) / databases (可选) - * @return array ['status' => 1|2|3, 'text' => string] + * @param array $expert expert 行(含 name / representative_work_title / fields 等) + * @param array $journal journal 行(含 title / aims / databases 等) + * @param array $overlapFields 预先计算的 expert & journal 领域交集(可能为空) + * @param array $journalFields journal(或 task/工厂)的目标领域 + * @param array $expertFields expert 的研究领域 + * @return array [ + * 'description' => string, + * 'description_status' => 1|2|3, + * 'advised_topics' => string, + * 'advised_topics_status' => 1|2|3, + * ] */ - public function generateDescription(array $expert, array $journal): array - { + public function generateEmailContent( + array $expert, + array $journal, + array $overlapFields = [], + array $journalFields = [], + array $expertFields = [] + ): array { $paperTitle = trim((string)($expert['representative_work_title'] ?? '')); $expertName = trim((string)($expert['name'] ?? '')); $journalName = trim((string)($journal['title'] ?? '')); - if ($paperTitle === '' || $journalName === '') { - return ['status' => 3, 'text' => $this->fallback]; + $overlapList = $this->cleanList($overlapFields); + $journalList = $this->cleanList($journalFields); + $expertList = $this->cleanList($expertFields); + + $hasDescInput = ($paperTitle !== '' && $journalName !== ''); + $hasAdvisedInput = (!empty($overlapList) || (!empty($journalList) && !empty($expertList))); + + // 两段都缺输入时直接走双兜底 + if (!$hasDescInput && !$hasAdvisedInput) { + return $this->allFallback(3, 3); } + // LLM 未配置 if ($this->url === '' || $this->model === '') { - return ['status' => 2, 'text' => $this->fallback]; + return $this->allFallback( + $hasDescInput ? 2 : 3, + $hasAdvisedInput ? 2 : 3 + ); } $expertField = trim((string)($expert['fields'] ?? ($expert['field'] ?? ''))); @@ -66,27 +98,48 @@ class PromotionLlmService $journalDbs = trim((string)($journal['databases'] ?? '')); $system = 'You are an academic editorial assistant. ' - . 'Write a short, warm, professional English paragraph (2-3 sentences, <=50 words) ' - . 'that: (1) briefly appreciates the author\'s recent paper, ' - . '(2) explains why it fits the journal\'s scope, ' - . '(3) gently invites a future submission. ' - . 'Do NOT use placeholders, do NOT add greetings, do NOT add signatures, ' - . 'output plain text only (no markdown).'; + . 'You will receive context about an author, their recent paper, and a target journal, ' + . 'and you must produce TWO English paragraphs for an invitation email. ' + . 'Output STRICT MINIFIED JSON ONLY with exactly these keys:' + . '{"description":"...","advised_topics":"..."} ' + . 'Rules for "description": 2-3 sentences, <=50 words, warm and professional; ' + . '(a) briefly appreciate the author\'s recent paper, ' + . '(b) explain why it fits the journal\'s scope, ' + . '(c) gently invite a future submission. ' + . 'Rules for "advised_topics": 1-2 sentences, <=40 words; ' + . 'emphasize that the journal is particularly interested in the research directions ' + . 'where the journal\'s focus and the author\'s work overlap; ' + . 'mention the overlapping topics explicitly (use the provided overlap list when non-empty, ' + . 'otherwise choose the best semantic overlap between journal focus and author fields); ' + . 'end by inviting contributions leaning toward those directions. ' + . 'No greetings, no signatures, no placeholders, no markdown, no code fences. ' + . 'If a section genuinely cannot be produced, return an empty string for that key.'; $userLines = []; $userLines[] = 'Author name: ' . ($expertName !== '' ? $expertName : '(unknown)'); if ($expertField !== '') { - $userLines[] = 'Author research field: ' . $expertField; + $userLines[] = 'Author research field (raw): ' . $expertField; + } + if (!empty($expertList)) { + $userLines[] = 'Author research fields (list): ' . implode(', ', $expertList); + } + $userLines[] = 'Recent paper title: ' . ($paperTitle !== '' ? $paperTitle : '(none)'); + $userLines[] = 'Target journal: ' . ($journalName !== '' ? $journalName : '(unknown)'); + if (!empty($journalList)) { + $userLines[] = 'Journal focus fields: ' . implode(', ', $journalList); + } + if (!empty($overlapList)) { + $userLines[] = 'Overlap topics (exact match): ' . implode(', ', $overlapList); + } else { + $userLines[] = 'Overlap topics (exact match): (none, infer semantically from the two field lists above)'; } - $userLines[] = 'Recent paper title: ' . $paperTitle; - $userLines[] = 'Target journal: ' . $journalName; if ($journalAims !== '') { $userLines[] = 'Journal aims & scope: ' . mb_substr($journalAims, 0, 500); } if ($journalDbs !== '') { $userLines[] = 'Journal indexing: ' . mb_substr($journalDbs, 0, 200); } - $userLines[] = 'Return only the paragraph, nothing else.'; + $userLines[] = 'Return only minified JSON {"description":"...","advised_topics":"..."}.'; $user = implode("\n", $userLines); $payload = [ @@ -100,15 +153,55 @@ class PromotionLlmService $content = $this->postChat($payload); if ($content === null) { - return ['status' => 2, 'text' => $this->fallback]; + return $this->allFallback( + $hasDescInput ? 2 : 3, + $hasAdvisedInput ? 2 : 3 + ); } - $content = $this->cleanContent($content); - if ($content === '') { - return ['status' => 2, 'text' => $this->fallback]; + $parsed = $this->parseJson($content); + if ($parsed === null) { + return $this->allFallback( + $hasDescInput ? 2 : 3, + $hasAdvisedInput ? 2 : 3 + ); } - return ['status' => 1, 'text' => $content]; + $desc = $this->cleanContent((string)($parsed['description'] ?? '')); + $advised = $this->cleanContent((string)($parsed['advised_topics'] ?? '')); + + $descStatus = 1; + if ($desc === '') { + $desc = $this->fallback; + $descStatus = $hasDescInput ? 2 : 3; + } + + $advisedStatus = 1; + if ($advised === '') { + $advised = $this->advisedFallback; + $advisedStatus = $hasAdvisedInput ? 2 : 3; + } + + return [ + 'description' => $desc, + 'description_status' => $descStatus, + 'advised_topics' => $advised, + 'advised_topics_status' => $advisedStatus, + ]; + } + + /** + * 兼容旧接口:单段 description 生成(仍走 generateEmailContent,取第一段) + * + * @return array ['status' => 1|2|3, 'text' => string] + */ + public function generateDescription(array $expert, array $journal): array + { + $r = $this->generateEmailContent($expert, $journal); + return [ + 'status' => intval($r['description_status']), + 'text' => (string)$r['description'], + ]; } /** @@ -153,6 +246,28 @@ class PromotionLlmService return null; } + /** + * 从模型原始输出里提取 JSON 对象;失败返回 null。 + */ + private function parseJson(string $raw) + { + $raw = trim($raw); + if ($raw === '') return null; + $raw = preg_replace('/^```[a-zA-Z]*\s*|```$/m', '', $raw); + $raw = trim($raw); + + // 直接 decode + $obj = json_decode($raw, true); + if (is_array($obj)) return $obj; + + // 抓出第一个 {...} 块再 decode + if (preg_match('/\{.*\}/s', $raw, $m)) { + $obj = json_decode($m[0], true); + if (is_array($obj)) return $obj; + } + return null; + } + /** * 清洗 LLM 输出:去除 markdown 包裹、多余空白、首尾引号、过长截断。 */ @@ -169,8 +284,41 @@ class PromotionLlmService return trim($text); } + /** + * 对领域列表做 trim / 去空 / 去重,保留首次出现顺序。 + */ + private function cleanList(array $list): array + { + $out = []; + $seen = []; + foreach ($list as $item) { + $v = trim((string)$item); + if ($v === '') continue; + $key = strtolower($v); + if (isset($seen[$key])) continue; + $seen[$key] = true; + $out[] = $v; + } + return $out; + } + + private function allFallback(int $descStatus, int $advisedStatus): array + { + return [ + 'description' => $this->fallback, + 'description_status' => $descStatus, + 'advised_topics' => $this->advisedFallback, + 'advised_topics_status' => $advisedStatus, + ]; + } + public function getFallback(): string { return $this->fallback; } + + public function getAdvisedFallback(): string + { + return $this->advisedFallback; + } } diff --git a/application/common/PromotionService.php b/application/common/PromotionService.php index d77b5f8..54a6ab0 100644 --- a/application/common/PromotionService.php +++ b/application/common/PromotionService.php @@ -123,6 +123,17 @@ class PromotionService } $expert['fields'] = implode(',', $fieldSet); $expert['representative_work_title'] = $matchedTitle !== '' ? $matchedTitle : $fallbackTitle; + + // 现场发送路径:没有提前准备 LLM,退回 .env 兜底文案 + try { + $llmSvc = new PromotionLlmService(); + $expert['llm_description'] = $llmSvc->getFallback(); + $expert['ai_advised_topics'] = $llmSvc->getAdvisedFallback(); + } catch (\Exception $e) { + $expert['llm_description'] = ''; + $expert['ai_advised_topics'] = ''; + } + $expertVars = $this->buildExpertVars($expert); $journalVars = $this->buildJournalVars($journal); $vars = array_merge($journalVars, $expertVars); @@ -312,21 +323,60 @@ class PromotionService $expert['fields'] = implode(',', $fieldSet); $expert['representative_work_title'] = $matchedTitle !== '' ? $matchedTitle : $fallbackTitle; + // 领域交集(大小写不敏感,保留 expert 原字面值用于展示) + $overlapFields = []; + if (!empty($taskFieldLower)) { + foreach ($fieldSet as $fn) { + if (isset($taskFieldLower[strtolower($fn)])) { + $overlapFields[] = $fn; + } + } + } + $journal = Db::name('journal')->where('journal_id', $task['journal_id'])->find(); - // 调用 LLM 生成个性化描述(失败/缺条件时回退到兜底文案) - $llmResult = ['status' => 0, 'text' => '']; + // 一次 LLM 调用生成两段内容(description + advised_topics) + $llmResult = [ + 'description' => '', + 'description_status' => 0, + 'advised_topics' => '', + 'advised_topics_status' => 0, + ]; try { $llm = new PromotionLlmService(); - $llmResult = $llm->generateDescription($expert, $journal ?: []); + $llmResult = $llm->generateEmailContent( + $expert, + $journal ?: [], + $overlapFields, + $taskFields, + $fieldSet + ); } catch (\Exception $e) { - $llmResult = ['status' => 2, 'text' => '']; + // 兜底双占位($llm 实例可能未成功构建,单独拿一个尝试) + $fbDesc = ''; + $fbAdvised = ''; + try { + $fbSvc = isset($llm) ? $llm : new PromotionLlmService(); + $fbDesc = $fbSvc->getFallback(); + $fbAdvised = $fbSvc->getAdvisedFallback(); + } catch (\Exception $ignore) { + // 忽略,使用空串 + } + $llmResult = [ + 'description' => $fbDesc, + 'description_status' => 2, + 'advised_topics' => $fbAdvised, + 'advised_topics_status' => 2, + ]; $this->log("prepareSingleEmail log_id={$logId} llm_exception=" . $e->getMessage()); } - $llmText = (string)$llmResult['text']; - $llmStatus = intval($llmResult['status']); + $llmText = (string)$llmResult['description']; + $llmStatus = intval($llmResult['description_status']); + $advisedText = (string)$llmResult['advised_topics']; + $advisedStatus = intval($llmResult['advised_topics_status']); - $expert['llm_description'] = $llmText; + $expert['llm_description'] = $llmText; + $expert['ai_advised_topics'] = $advisedText; $expertVars = $this->buildExpertVars($expert); $journalVars = $this->buildJournalVars($journal); @@ -341,27 +391,41 @@ class PromotionService $now = time(); if ($rendered['code'] !== 0) { Db::name('promotion_email_log')->where('log_id', $logId)->update([ - 'state' => 2, - 'error_msg' => 'Prepare failed: ' . $rendered['msg'], - 'llm_description' => mb_substr($llmText, 0, 2000), - 'llm_status' => $llmStatus, - 'send_time' => $now, + 'state' => 2, + 'error_msg' => 'Prepare failed: ' . $rendered['msg'], + 'llm_description' => mb_substr($llmText, 0, 2000), + 'llm_status' => $llmStatus, + 'llm_advised_topics' => mb_substr($advisedText, 0, 2000), + 'llm_advised_topics_status' => $advisedStatus, + 'send_time' => $now, ]); $this->tryFinalizeTask($task['task_id']); - return ['code' => 1, 'msg' => $rendered['msg'], 'llm_status' => $llmStatus]; + return [ + 'code' => 1, + 'msg' => $rendered['msg'], + 'llm_status' => $llmStatus, + 'llm_advised_topics_status' => $advisedStatus, + ]; } Db::name('promotion_email_log')->where('log_id', $logId)->update([ - 'subject_prepared' => mb_substr($rendered['data']['subject'], 0, 512), - 'body_prepared' => $rendered['data']['body'], - 'llm_description' => mb_substr($llmText, 0, 2000), - 'llm_status' => $llmStatus, - 'prepared_at' => $now, + 'subject_prepared' => mb_substr($rendered['data']['subject'], 0, 512), + 'body_prepared' => $rendered['data']['body'], + 'llm_description' => mb_substr($llmText, 0, 2000), + 'llm_status' => $llmStatus, + 'llm_advised_topics' => mb_substr($advisedText, 0, 2000), + 'llm_advised_topics_status' => $advisedStatus, + 'prepared_at' => $now, ]); $this->tryFinalizeTask($task['task_id']); - return ['code' => 0, 'msg' => 'ok', 'llm_status' => $llmStatus]; + return [ + 'code' => 0, + 'msg' => 'ok', + 'llm_status' => $llmStatus, + 'llm_advised_topics_status' => $advisedStatus, + ]; } /** @@ -725,16 +789,19 @@ class PromotionService public function buildExpertVars($expert) { - $llm = $expert['llm_description'] ?? ''; + $llm = $expert['llm_description'] ?? ''; + $advised = $expert['ai_advised_topics'] ?? ''; return [ - 'expert_title' => "Ph.D", - 'expert_name' => $expert['name'] ?? '', - 'expert_email' => $expert['email'] ?? '', - 'expert_affiliation' => $expert['affiliation'] ?? '', - 'expert_field' => $expert['fields'] ?? ($expert['field'] ?? ''), + 'expert_title' => "Ph.D", + 'expert_name' => $expert['name'] ?? '', + 'expert_email' => $expert['email'] ?? '', + 'expert_affiliation' => $expert['affiliation'] ?? '', + 'expert_field' => $expert['fields'] ?? ($expert['field'] ?? ''), 'representative_work_title' => $expert['representative_work_title'] ?? '', - 'llm_description' => $llm, - 'ai_content_analysis' => $llm, + 'llm_description' => $llm, + 'ai_content_analysis' => $llm, + 'ai_advised_topics' => $advised, + 'llm_advised_topics' => $advised, ]; } diff --git a/sql/add_llm_advised_topics_to_promotion_email_log.sql b/sql/add_llm_advised_topics_to_promotion_email_log.sql new file mode 100644 index 0000000..88d3fdb --- /dev/null +++ b/sql/add_llm_advised_topics_to_promotion_email_log.sql @@ -0,0 +1,7 @@ +-- 为 t_promotion_email_log 增加第二段 AI 生成字段(推荐投稿方向) +-- llm_advised_topics: 大模型生成的"我们特别关注 X/Y/Z 领域"段落,对应模板变量 {{ai_advised_topics}} +-- llm_advised_topics_status: 0未处理 1成功 2失败兜底 3跳过(无可用领域) + +ALTER TABLE `t_promotion_email_log` + ADD COLUMN `llm_advised_topics` TEXT NULL COMMENT '大模型生成的推荐投稿方向文案' AFTER `llm_description`, + ADD COLUMN `llm_advised_topics_status` TINYINT NOT NULL DEFAULT 0 COMMENT 'AI 推荐方向状态: 0未处理 1成功 2失败兜底 3跳过' AFTER `llm_advised_topics`;