useLlm = isset($config['use_llm']) ? (bool)$config['use_llm'] : true; } /** * @return array author/title/publisher/place/year/edition/pages/isbn/container */ public function parse($content) { $out = [ 'author' => '', 'title' => '', 'publisher' => '', 'place' => '', 'year' => '', 'edition' => '', 'pages' => '', 'isbn' => '', 'container' => '', ]; $clean = $this->normalize($content); if ($clean === '') { return $out; } $out['isbn'] = $this->matchIsbn($clean); $clean = $this->stripTail($clean); // 章节引用:"章节作者. 章节名. In: 编者. 书名. 版次. 地点: 出版社; 年. 页码" // 出版信息属于 In: 之后的那本书,必须分开解析,否则会把编者当成书名 if (preg_match('/^(.*?)\bIn\s*:\s*(.+)$/isu', $clean, $m) && trim($m[1]) !== '' && trim($m[2]) !== '') { $chapter = trim($m[1]); $book = trim($m[2]); $this->parseSegments($this->takeAuthor($chapter, $out['author']), $out); $bookOut = $out; $bookOut['title'] = ''; $editors = ''; $this->parseSegments($this->takeAuthor($book, $editors), $bookOut); $out['container'] = $bookOut['title']; foreach (['publisher', 'place', 'year', 'edition', 'pages'] as $field) { if ($out[$field] === '') { $out[$field] = $bookOut[$field]; } } if ($out['author'] === '') { $out['author'] = $editors; } } else { $this->parseSegments($this->takeAuthor($clean, $out['author']), $out); } if ($this->needsLlm($out)) { $out = $this->refineByLlm($clean, $out); } return $out; } /** * 去掉条目序号:"1. " / "[1] " / "(1) " / "1) " / "1、" */ public function stripIndex($content) { return preg_replace('/^\s*(?:\[\d{1,4}\]|\(\d{1,4}\)|\d{1,4}\s*[\.\)\]、,])\s*/u', '', (string)$content); } private function normalize($content) { $content = trim((string)$content); $content = str_replace( [':', ';', ',', '(', ')', '[', ']', ' ', '.'], [':', ';', ',', '(', ')', '[', ']', ' ', '.'], $content ); $content = preg_replace('/\s+/u', ' ', $content); $content = $this->stripIndex($content); // 中文句号统一成英文句点,便于按同一套规则切分 $content = str_replace('。', '. ', $content); return trim(preg_replace('/\s+/u', ' ', $content)); } /** * 去掉尾部的获取途径(Available at / Accessed / URL / DOI),它们会干扰分段 */ private function stripTail($text) { $text = preg_replace('/\s*(?:Available\s*(?:at|from)|Retrieved\s+from|Accessed\s+(?:on\s+)?)\s*:?.*$/iu', '', $text); $text = preg_replace('/\s*https?:\/\/\S+/iu', '', $text); $text = preg_replace('/\s*\bdoi\s*:\s*\S+/iu', '', $text); $text = preg_replace('/\s*\bISBN(?:-1[03])?\s*:?\s*[\d\-\sXx]{10,20}\.?/iu', '', $text); return trim($text); } private function matchIsbn($text) { if (!preg_match('/\bISBN(?:-1[03])?[:\s]*([0-9][0-9\-\s]{8,20}[0-9Xx])/i', $text, $m)) { return ''; } return preg_replace('/[^0-9Xx]/', '', $m[1]); } // ------------------------------------------------------------------ // 作者 // ------------------------------------------------------------------ /** * 从头部切出作者,返回剩余部分 */ private function takeAuthor($text, &$author) { $author = ''; // 温哥华格式:Surname AB, Surname CD, et al.(可带 editor/eds 标记) $name = '\p{Lu}[\p{L}\'\x{2019}\-]+(?:\s+\p{Lu}[\p{L}\'\x{2019}\-]+)*\s+\p{Lu}{1,4}'; $pattern = '/^((?:' . $name . ')(?:\s*,\s*(?:' . $name . '))*' . '(?:\s*,?\s*et\s+al\.?)?)(?:\s*,\s*(?:eds?|editors?))?\s*[\.,]\s+/u'; if (preg_match($pattern, $text, $m)) { $author = trim($m[1], " ,."); return trim(substr($text, strlen($m[0]))); } // 机构作者 $org = '/^([\p{Lu}][\p{L}\s&\-,\.]{4,90}?(?:Organization|Organisation|Association|Society|Institute' . '|Institutes|Ministry|Committee|Council|Administration|Agency|Department|Bureau|Foundation' . '|Academy|Commission|Centers?|WHO|CDC|NIH|FDA|NICE|UNICEF|OECD))\.\s+/u'; if (preg_match($org, $text, $m)) { $author = trim($m[1], " ,."); return trim(substr($text, strlen($m[0]))); } // 中文作者:张三, 李四, 等. $cn = '/^([\x{4e00}-\x{9fff}·]{2,10}(?:\s*[,、;]\s*[\x{4e00}-\x{9fff}·]{2,10})*(?:\s*,?\s*等)?)\s*[\.,]\s*/u'; if (preg_match($cn, $text, $m)) { $author = trim($m[1], " ,."); return trim(substr($text, strlen($m[0]))); } // 兜底:第一个句点之前 $parts = preg_split('/\.\s+/u', $text, 2); if (is_array($parts) && count($parts) === 2 && mb_strlen($parts[0], 'UTF-8') <= 120) { $author = trim($parts[0], " ,."); return trim($parts[1]); } return $text; } // ------------------------------------------------------------------ // 分段与归类 // ------------------------------------------------------------------ private function parseSegments($text, array &$out) { foreach ($this->splitSegments($text) as $seg) { $seg = trim($seg, " .,;"); if ($seg === '') { continue; } // "6th ed. New York: Garland Science; 2015" 里版次和出版信息粘在一段, // 摘掉版次再往下判,否则出版地会变成 "6th ed. New York" $edition = $this->matchEdition($seg); if ($edition !== '') { if ($out['edition'] === '') { $out['edition'] = $edition; } $seg = trim($this->stripEdition($seg), " .,;"); if ($seg === '') { continue; } } if ($out['pages'] === '' && preg_match('/^(?:p{1,2}\.?|pages?)\s*([\divxlcIVXLC][\divxlcIVXLC\-\x{2013}]*)$/iu', $seg, $m)) { $out['pages'] = $m[1]; continue; } if ($this->takePublication($seg, $out)) { continue; } if ($out['title'] === '') { $out['title'] = $this->cleanTitle($seg); } } if ($out['year'] === '' && preg_match_all('/\b(?:19|20)\d{2}\b/', $text, $ym)) { $out['year'] = end($ym[0]); } } /** * 缩写后的句点不是分段边界(ed. / vol. / p. / Inc. / 姓名缩写) * * @return string[] */ private function splitSegments($text) { $abbr = 'ed|eds|edn|vol|vols|no|nos|pp|p|st|mt|dr|prof|inc|ltd|co|jr|sr|al|rev|suppl|fig|ch|pt|approx'; $masked = preg_replace('/\b(' . $abbr . ')\.(?=\s)/iu', '$1' . "\x01", $text); // "Kumar V. Abbas AK." 这类姓名缩写之间的点 $masked = preg_replace('/\b(\p{Lu})\.(?=\s*\p{Lu}\b)/u', '$1' . "\x01", $masked); $segments = preg_split('/\.\s+|\.$/u', $masked); $out = []; foreach ((array)$segments as $seg) { $out[] = str_replace("\x01", '.', $seg); } return $out; } private function matchEdition($seg) { if (preg_match('/\b(\d{1,2})\s*(?:st|nd|rd|th|d)?\s*(?:ed\.?|edn\.?|edition)\b/iu', $seg, $m)) { return $m[1]; } if (preg_match('/\b(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)\s+(?:ed\.?|edn\.?|edition)\b/iu', $seg, $m)) { return strtolower($m[1]); } if (preg_match('/(?:第\s*)?(\d{1,2})\s*版/u', $seg, $m)) { return $m[1]; } return ''; } private function stripEdition($seg) { $seg = preg_replace('/\b\d{1,2}\s*(?:st|nd|rd|th|d)?\s*(?:ed\.?|edn\.?|edition)\b[\.,;]?/iu', '', $seg); $seg = preg_replace('/\b(?:first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)\s+(?:ed\.?|edn\.?|edition)\b[\.,;]?/iu', '', $seg); $seg = preg_replace('/(?:第\s*)?\d{1,2}\s*版/u', '', $seg); return $seg; } /** * 出版信息段:Place: Publisher; Year / Publisher, Year / 北京: 人民卫生出版社, 2018 */ private function takePublication($seg, array &$out) { $hasYear = preg_match('/\b((?:19|20)\d{2})\b/', $seg, $ym); $hasPlace = preg_match('/^([^:]{2,60}):\s*(.+)$/u', $seg, $pm); if (!$hasYear && !$hasPlace) { return false; } // "Depression in adults: treatment and management" 是带副标题的书名,不是"地点: 出版社" if ($hasPlace && !$hasYear && ($out['title'] === '' || !$this->looksLikePublisher($pm[2]))) { return false; } // 标题里出现年份(如 "Global tuberculosis report 2020")不算出版信息段 if ($hasYear && !$hasPlace && $out['title'] === '' && !preg_match('/^\W*(?:19|20)\d{2}\W*$/', $seg)) { $withoutYear = trim(preg_replace('/\b(?:19|20)\d{2}\b/', '', $seg), " .,;"); if ($withoutYear !== '' && !$this->looksLikePublisher($withoutYear)) { return false; } } if ($hasYear && $out['year'] === '') { $out['year'] = $ym[1]; } $body = $hasPlace ? trim($pm[2]) : $seg; if ($hasPlace && $out['place'] === '') { $out['place'] = trim($pm[1], " .,;"); } $body = trim(preg_replace('/\b(?:19|20)\d{2}\b/', '', $body), " .,;:"); if ($body !== '' && $out['publisher'] === '') { $out['publisher'] = $body; } return true; } private function looksLikePublisher($text) { return (bool)preg_match( '/(Press|Publish\w*|Books?|Elsevier|Springer|Wiley|Saunders|Mosby|Lippincott|Williams|Wilkins' . '|McGraw|Academic|University|Univ\b|出版社|书局|WHO|Organization)/iu', $text ); } private function cleanTitle($title) { $title = trim((string)$title); // GB/T 7714 的文献类型标识:[M] [M/OL] [C] 等 $title = preg_replace('/\s*\[[A-Z]{1,2}(?:\/[A-Z]{1,2})?\]\s*/u', ' ', $title); $title = preg_replace('/\s+/u', ' ', $title); return trim($title, " .,;:"); } // ------------------------------------------------------------------ // 大模型兜底 // ------------------------------------------------------------------ private function needsLlm(array $out) { if (!$this->useLlm) { return false; } if ($out['title'] === '' || $out['author'] === '') { return true; } // 分段错位时标题里会留下一串人名 return $this->looksLikeAuthorList($out['title']); } private function looksLikeAuthorList($text) { $text = trim((string)$text); if ($text === '') { return false; } $name = '\p{Lu}[\p{L}\'\-]+\s+\p{Lu}{1,4}'; return (bool)preg_match('/^(?:' . $name . ')(?:\s*,\s*(?:' . $name . '))*(?:\s*,?\s*et\s+al\.?)?$/u', $text); } private function refineByLlm($content, array $out) { try { $llm = new LLMService(); $system = 'You extract bibliographic fields from a single book (or book chapter) reference string. ' . 'Return ONLY a JSON object with these keys: ' . '{"author":"","title":"","publisher":"","place":"","year":"","edition":"","pages":""}. ' . 'Rules: author = the author or editor list exactly as written, without a trailing period; ' . 'title = the title of the cited work only, without edition, publisher, place or year; ' . 'for a chapter reference, title = the chapter title; ' . 'publisher = publisher name only, no place; year = 4-digit publication year; ' . 'edition = the edition number as digits only (e.g. "6"), empty if not stated; ' . 'pages = page range if stated. Use an empty string for anything not stated. ' . 'Do not translate, do not invent, do not add explanation.'; $user = "Reference:\n" . mb_substr($content, 0, 1500); $reply = $llm->requestChat([ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $user], ], 0); if ($reply === null || $reply === '') { return $out; } $parsed = $llm->parseJsonResponse($reply); if (!is_array($parsed)) { return $out; } foreach (['author', 'title', 'publisher', 'place', 'year', 'edition', 'pages'] as $field) { $value = trim((string)($parsed[$field] ?? '')); if ($value === '') { continue; } // 模型不得改写原文里没有的内容 if ($field === 'year' && !preg_match('/^(?:19|20)\d{2}$/', $value)) { continue; } if ($field === 'edition') { $value = preg_match('/\d{1,2}/', $value, $m) ? $m[0] : ''; if ($value === '') { continue; } } if ($field === 'title' && $this->looksLikeAuthorList($value)) { continue; } $out[$field] = $field === 'title' ? $this->cleanTitle($value) : $value; } } catch (\Throwable $e) { \think\Log::write('book citation llm parse failed: ' . $e->getMessage(), 'error'); } return $out; } }