Files
tougao/application/common/BookCitationParser.php
2026-08-07 16:04:59 +08:00

406 lines
15 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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;
}
}