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