Files
tougao/application/common/PromotionLlmService.php
2026-04-24 13:13:06 +08:00

177 lines
6.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common;
use think\Env;
/**
* 推广邮件 LLM 服务
*
* 作用:基于 expert 的代表作 (paper_title) + 期刊信息 (name / scope 等)
* 生成一段 2-3 句的个性化描述,用于邮件模板变量 {{llm_description}}。
*
* 配置(.env 的 [promotion] 段):
* PROMOTION_LLM_URL chat/completions 接口地址
* PROMOTION_LLM_MODEL 模型名
* PROMOTION_LLM_TIMEOUT 超时时间(秒),默认 30
* PROMOTION_LLM_FALLBACK 兜底描述LLM 不可用 / 调用失败时使用)
*
* 返回约定:
* ['status' => 1|2|3, 'text' => string]
* 1 = LLM 成功生成
* 2 = LLM 调用失败text = 兜底文案
* 3 = 跳过缺少代表作等前置条件text = 兜底文案
*/
class PromotionLlmService
{
private $url;
private $model;
private $timeout;
private $apiKey;
private $fallback;
public function __construct()
{
$this->url = trim((string)Env::get('promotion.promotion_llm_url', ''));
$this->model = trim((string)Env::get('promotion.promotion_llm_model', ''));
$this->timeout = max(5, intval(Env::get('promotion.promotion_llm_timeout', 30)));
$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.'));
}
/**
* 生成个性化描述
*
* @param array $expert 至少包含 name / representative_work_title / fields / affiliation
* @param array $journal 至少包含 title (name) / aims (可选) / databases (可选)
* @return array ['status' => 1|2|3, 'text' => string]
*/
public function generateDescription(array $expert, array $journal): 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];
}
if ($this->url === '' || $this->model === '') {
return ['status' => 2, 'text' => $this->fallback];
}
$expertField = trim((string)($expert['fields'] ?? ($expert['field'] ?? '')));
$journalAims = trim((string)($journal['aims'] ?? ''));
$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).';
$userLines = [];
$userLines[] = 'Author name: ' . ($expertName !== '' ? $expertName : '(unknown)');
if ($expertField !== '') {
$userLines[] = 'Author research field: ' . $expertField;
}
$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.';
$user = implode("\n", $userLines);
$payload = [
'model' => $this->model,
'temperature' => 0.4,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
],
];
$content = $this->postChat($payload);
if ($content === null) {
return ['status' => 2, 'text' => $this->fallback];
}
$content = $this->cleanContent($content);
if ($content === '') {
return ['status' => 2, 'text' => $this->fallback];
}
return ['status' => 1, 'text' => $content];
}
/**
* 调用 chat/completions 接口,返回 content 字符串;失败返回 null。
*/
private function postChat(array $payload)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(10, $this->timeout));
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
$headers = ['Content-Type: application/json'];
if ($this->apiKey !== '') {
$headers[] = 'Authorization: Bearer ' . $this->apiKey;
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$raw = curl_exec($ch);
if ($raw === false) {
curl_close($ch);
return null;
}
$httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE));
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300) {
return null;
}
$data = json_decode($raw, true);
if (!is_array($data)) return null;
if (isset($data['choices'][0]['message']['content'])) {
return (string)$data['choices'][0]['message']['content'];
}
if (isset($data['content'])) {
return (string)$data['content'];
}
return null;
}
/**
* 清洗 LLM 输出:去除 markdown 包裹、多余空白、首尾引号、过长截断。
*/
private function cleanContent(string $text): string
{
$text = trim($text);
$text = preg_replace('/^```[a-zA-Z]*\s*|```$/m', '', $text);
$text = trim($text);
$text = trim($text, "\"' \t\n\r\0\x0B");
$text = preg_replace('/\s+/', ' ', $text);
if (mb_strlen($text) > 800) {
$text = mb_substr($text, 0, 800);
}
return trim($text);
}
public function getFallback(): string
{
return $this->fallback;
}
}