修改自动推广的相关任务

This commit is contained in:
wangjinlei
2026-04-24 14:50:16 +08:00
parent 7a204961de
commit 13fd3af816
4 changed files with 285 additions and 62 deletions

1
.env
View File

@@ -24,6 +24,7 @@ PROMOTION_LLM_URL=http://chat.taimed.cn/v1/chat/completions
PROMOTION_LLM_MODEL=your-model-name PROMOTION_LLM_MODEL=your-model-name
PROMOTION_LLM_TIMEOUT=30 PROMOTION_LLM_TIMEOUT=30
PROMOTION_LLM_FALLBACK="" PROMOTION_LLM_FALLBACK=""
PROMOTION_LLM_ADVISED_FALLBACK=""
[journal] [journal]
;官网服务器地址 ;官网服务器地址

View File

@@ -7,20 +7,24 @@ use think\Env;
/** /**
* 推广邮件 LLM 服务 * 推广邮件 LLM 服务
* *
* 作用:基于 expert 的代表作 (paper_title) + 期刊信息 (name / scope 等) * 作用:基于 expert 的代表作 (paper_title)、expert 的研究领域、期刊信息 (name / scope / fields 等)
* 生成一段 2-3 句的个性化描述,用于邮件模板变量 {{llm_description}}。 * 通过一次 LLM 调用同时生成两段邮件内容:
* - description 个性化赞美 + 邀稿段落(对应模板变量 {{ai_content_analysis}} / {{llm_description}}
* - advised_topics "我们特别关注 X / Y / Z 领域" 段落(对应模板变量 {{ai_advised_topics}}
*
* 单次调用返回 JSON避免两次 LLM 请求导致的延迟翻倍;任一段失败可独立兜底。
* *
* 配置(.env 的 [promotion] 段): * 配置(.env 的 [promotion] 段):
* PROMOTION_LLM_URL chat/completions 接口地址 * PROMOTION_LLM_URL chat/completions 接口地址
* PROMOTION_LLM_MODEL 模型名 * PROMOTION_LLM_MODEL 模型名
* PROMOTION_LLM_TIMEOUT 超时时间(秒),默认 30 * PROMOTION_LLM_TIMEOUT 超时时间(秒),默认 30
* PROMOTION_LLM_FALLBACK 兜底描述LLM 不可用 / 调用失败时使用) * PROMOTION_LLM_FALLBACK description 兜底文案
* PROMOTION_LLM_ADVISED_FALLBACK advised_topics 兜底文案
* *
* 返回约定: * 状态约定:
* ['status' => 1|2|3, 'text' => string] * 1 = LLM 成功生成
* 1 = LLM 成功生成 * 2 = LLM 调用失败 / 解析失败,使用兜底
* 2 = LLM 调用失败text = 兜底文案 * 3 = 前置条件不足(缺代表作、缺交集等),使用兜底
* 3 = 跳过缺少代表作等前置条件text = 兜底文案
*/ */
class PromotionLlmService class PromotionLlmService
{ {
@@ -29,6 +33,7 @@ class PromotionLlmService
private $timeout; private $timeout;
private $apiKey; private $apiKey;
private $fallback; private $fallback;
private $advisedFallback;
public function __construct() public function __construct()
{ {
@@ -38,27 +43,54 @@ class PromotionLlmService
$this->apiKey = trim((string)Env::get('promotion.promotion_llm_api_key', '')); $this->apiKey = trim((string)Env::get('promotion.promotion_llm_api_key', ''));
$this->fallback = trim((string)Env::get('promotion.promotion_llm_fallback', $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.')); '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 $expert expert 行(含 name / representative_work_title / fields 等)
* @param array $journal 至少包含 title (name) / aims (可选) / databases (可选) * @param array $journal journal 行(含 title / aims / databases 等)
* @return array ['status' => 1|2|3, 'text' => string] * @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'] ?? '')); $paperTitle = trim((string)($expert['representative_work_title'] ?? ''));
$expertName = trim((string)($expert['name'] ?? '')); $expertName = trim((string)($expert['name'] ?? ''));
$journalName = trim((string)($journal['title'] ?? '')); $journalName = trim((string)($journal['title'] ?? ''));
if ($paperTitle === '' || $journalName === '') { $overlapList = $this->cleanList($overlapFields);
return ['status' => 3, 'text' => $this->fallback]; $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 === '') { 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'] ?? ''))); $expertField = trim((string)($expert['fields'] ?? ($expert['field'] ?? '')));
@@ -66,27 +98,48 @@ class PromotionLlmService
$journalDbs = trim((string)($journal['databases'] ?? '')); $journalDbs = trim((string)($journal['databases'] ?? ''));
$system = 'You are an academic editorial assistant. ' $system = 'You are an academic editorial assistant. '
. 'Write a short, warm, professional English paragraph (2-3 sentences, <=50 words) ' . 'You will receive context about an author, their recent paper, and a target journal, '
. 'that: (1) briefly appreciates the author\'s recent paper, ' . 'and you must produce TWO English paragraphs for an invitation email. '
. '(2) explains why it fits the journal\'s scope, ' . 'Output STRICT MINIFIED JSON ONLY with exactly these keys:'
. '(3) gently invites a future submission. ' . '{"description":"...","advised_topics":"..."} '
. 'Do NOT use placeholders, do NOT add greetings, do NOT add signatures, ' . 'Rules for "description": 2-3 sentences, <=50 words, warm and professional; '
. 'output plain text only (no markdown).'; . '(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 = [];
$userLines[] = 'Author name: ' . ($expertName !== '' ? $expertName : '(unknown)'); $userLines[] = 'Author name: ' . ($expertName !== '' ? $expertName : '(unknown)');
if ($expertField !== '') { 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 !== '') { if ($journalAims !== '') {
$userLines[] = 'Journal aims & scope: ' . mb_substr($journalAims, 0, 500); $userLines[] = 'Journal aims & scope: ' . mb_substr($journalAims, 0, 500);
} }
if ($journalDbs !== '') { if ($journalDbs !== '') {
$userLines[] = 'Journal indexing: ' . mb_substr($journalDbs, 0, 200); $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); $user = implode("\n", $userLines);
$payload = [ $payload = [
@@ -100,15 +153,55 @@ class PromotionLlmService
$content = $this->postChat($payload); $content = $this->postChat($payload);
if ($content === null) { if ($content === null) {
return ['status' => 2, 'text' => $this->fallback]; return $this->allFallback(
$hasDescInput ? 2 : 3,
$hasAdvisedInput ? 2 : 3
);
} }
$content = $this->cleanContent($content); $parsed = $this->parseJson($content);
if ($content === '') { if ($parsed === null) {
return ['status' => 2, 'text' => $this->fallback]; 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; 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 包裹、多余空白、首尾引号、过长截断。 * 清洗 LLM 输出:去除 markdown 包裹、多余空白、首尾引号、过长截断。
*/ */
@@ -169,8 +284,41 @@ class PromotionLlmService
return trim($text); 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 public function getFallback(): string
{ {
return $this->fallback; return $this->fallback;
} }
public function getAdvisedFallback(): string
{
return $this->advisedFallback;
}
} }

View File

@@ -123,6 +123,17 @@ class PromotionService
} }
$expert['fields'] = implode(',', $fieldSet); $expert['fields'] = implode(',', $fieldSet);
$expert['representative_work_title'] = $matchedTitle !== '' ? $matchedTitle : $fallbackTitle; $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); $expertVars = $this->buildExpertVars($expert);
$journalVars = $this->buildJournalVars($journal); $journalVars = $this->buildJournalVars($journal);
$vars = array_merge($journalVars, $expertVars); $vars = array_merge($journalVars, $expertVars);
@@ -312,21 +323,60 @@ class PromotionService
$expert['fields'] = implode(',', $fieldSet); $expert['fields'] = implode(',', $fieldSet);
$expert['representative_work_title'] = $matchedTitle !== '' ? $matchedTitle : $fallbackTitle; $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(); $journal = Db::name('journal')->where('journal_id', $task['journal_id'])->find();
// 调用 LLM 生成个性化描述(失败/缺条件时回退到兜底文案 // 一次 LLM 调用生成两段内容description + advised_topics
$llmResult = ['status' => 0, 'text' => '']; $llmResult = [
'description' => '',
'description_status' => 0,
'advised_topics' => '',
'advised_topics_status' => 0,
];
try { try {
$llm = new PromotionLlmService(); $llm = new PromotionLlmService();
$llmResult = $llm->generateDescription($expert, $journal ?: []); $llmResult = $llm->generateEmailContent(
$expert,
$journal ?: [],
$overlapFields,
$taskFields,
$fieldSet
);
} catch (\Exception $e) { } 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()); $this->log("prepareSingleEmail log_id={$logId} llm_exception=" . $e->getMessage());
} }
$llmText = (string)$llmResult['text']; $llmText = (string)$llmResult['description'];
$llmStatus = intval($llmResult['status']); $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); $expertVars = $this->buildExpertVars($expert);
$journalVars = $this->buildJournalVars($journal); $journalVars = $this->buildJournalVars($journal);
@@ -341,27 +391,41 @@ class PromotionService
$now = time(); $now = time();
if ($rendered['code'] !== 0) { if ($rendered['code'] !== 0) {
Db::name('promotion_email_log')->where('log_id', $logId)->update([ Db::name('promotion_email_log')->where('log_id', $logId)->update([
'state' => 2, 'state' => 2,
'error_msg' => 'Prepare failed: ' . $rendered['msg'], 'error_msg' => 'Prepare failed: ' . $rendered['msg'],
'llm_description' => mb_substr($llmText, 0, 2000), 'llm_description' => mb_substr($llmText, 0, 2000),
'llm_status' => $llmStatus, 'llm_status' => $llmStatus,
'send_time' => $now, 'llm_advised_topics' => mb_substr($advisedText, 0, 2000),
'llm_advised_topics_status' => $advisedStatus,
'send_time' => $now,
]); ]);
$this->tryFinalizeTask($task['task_id']); $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([ Db::name('promotion_email_log')->where('log_id', $logId)->update([
'subject_prepared' => mb_substr($rendered['data']['subject'], 0, 512), 'subject_prepared' => mb_substr($rendered['data']['subject'], 0, 512),
'body_prepared' => $rendered['data']['body'], 'body_prepared' => $rendered['data']['body'],
'llm_description' => mb_substr($llmText, 0, 2000), 'llm_description' => mb_substr($llmText, 0, 2000),
'llm_status' => $llmStatus, 'llm_status' => $llmStatus,
'prepared_at' => $now, 'llm_advised_topics' => mb_substr($advisedText, 0, 2000),
'llm_advised_topics_status' => $advisedStatus,
'prepared_at' => $now,
]); ]);
$this->tryFinalizeTask($task['task_id']); $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) public function buildExpertVars($expert)
{ {
$llm = $expert['llm_description'] ?? ''; $llm = $expert['llm_description'] ?? '';
$advised = $expert['ai_advised_topics'] ?? '';
return [ return [
'expert_title' => "Ph.D", 'expert_title' => "Ph.D",
'expert_name' => $expert['name'] ?? '', 'expert_name' => $expert['name'] ?? '',
'expert_email' => $expert['email'] ?? '', 'expert_email' => $expert['email'] ?? '',
'expert_affiliation' => $expert['affiliation'] ?? '', 'expert_affiliation' => $expert['affiliation'] ?? '',
'expert_field' => $expert['fields'] ?? ($expert['field'] ?? ''), 'expert_field' => $expert['fields'] ?? ($expert['field'] ?? ''),
'representative_work_title' => $expert['representative_work_title'] ?? '', 'representative_work_title' => $expert['representative_work_title'] ?? '',
'llm_description' => $llm, 'llm_description' => $llm,
'ai_content_analysis' => $llm, 'ai_content_analysis' => $llm,
'ai_advised_topics' => $advised,
'llm_advised_topics' => $advised,
]; ];
} }

View File

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