406 lines
15 KiB
PHP
406 lines
15 KiB
PHP
<?php
|
||
|
||
namespace app\common;
|
||
|
||
use app\common\service\LLMService;
|
||
|
||
/**
|
||
* 图书类参考文献著录解析:原文字符串 → author / title / publisher / year / edition …
|
||
*
|
||
* 注意 refer_content 存的是带序号的原文("1. "、"[1] "、"1) "),
|
||
* 直接按第一个句点切分会把序号当作者、把作者当标题,所以必须先剥序号再解析。
|
||
*
|
||
* 规则解析拿不准时(缺标题、或标题看着像作者名单)再交给大模型兜底。
|
||
*/
|
||
class BookCitationParser
|
||
{
|
||
/** @var bool */
|
||
private $useLlm;
|
||
|
||
public function __construct(array $config = [])
|
||
{
|
||
$this->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;
|
||
}
|
||
}
|