参考文献格式--book

This commit is contained in:
wangjinlei
2026-08-07 16:04:59 +08:00
parent 175d2edc55
commit a1e151048c
3 changed files with 475 additions and 92 deletions

View File

@@ -0,0 +1,405 @@
<?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;
}
}

View File

@@ -120,29 +120,23 @@ class BookMetadataService
return $hints;
}
// 章节引用In: 编者. 书名. ...)反查的是书,不是章节
$bookPart = $content;
if (preg_match('/\bIn:\s*(.+)$/i', $content, $m)) {
$bookPart = trim($m[1]);
$hints['title'] = '';
// 反查用规则解析即可,不为了取个 ISBN 再多花一次大模型调用
$parsed = (new BookCitationParser(['use_llm' => false]))->parse($content);
// 章节引用反查的是书,不是章节
if ($parsed['container'] !== '') {
$hints['title'] = $parsed['container'];
} elseif ($hints['title'] === '') {
$hints['title'] = $parsed['title'];
}
$hints['edition'] = $this->parseEdition($content);
if ($hints['year'] === '' && preg_match_all('/\b(?:19|20)\d{2}\b/', $content, $ym)) {
$hints['year'] = end($ym[0]);
foreach (['author', 'publisher', 'edition'] as $field) {
if ($hints[$field] === '' && $parsed[$field] !== '') {
$hints[$field] = $parsed[$field];
}
// 地点: 出版社; 年份
if ($hints['publisher'] === '' && preg_match('/:\s*([^;:]{2,60}?)\s*[;,]\s*(?:19|20)\d{2}/u', $bookPart, $m)) {
$hints['publisher'] = trim($m[1], " .,");
}
if ($hints['title'] === '') {
$hints['title'] = $this->parseTitle($bookPart);
}
if ($hints['author'] === '') {
$hints['author'] = $this->parseAuthor($bookPart);
if ($hints['year'] === '') {
$hints['year'] = $parsed['year'];
}
return $hints;
@@ -432,38 +426,6 @@ class BookMetadataService
return trim($title, " .,;:");
}
private function parseTitle($text)
{
$parts = preg_split('/\.\s+/u', trim((string)$text), 3);
if (is_array($parts) && count($parts) >= 2) {
return $this->cleanTitle($parts[1]);
}
return '';
}
private function parseAuthor($text)
{
$parts = preg_split('/\.\s+/u', trim((string)$text), 2);
return is_array($parts) && count($parts) >= 2 ? trim($parts[0]) : '';
}
private function parseEdition($text)
{
if (preg_match('/\b(\d{1,2})\s*(?:st|nd|rd|th|d)?\s*(?:ed\.?|edition)\b/iu', $text, $m)) {
return $m[1];
}
if (preg_match('/\b(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)\s+(?:ed\.?|edition)\b/iu', $text, $m)) {
return $m[1];
}
if (preg_match('/第\s*(\d{1,2})\s*版/u', $text, $m)) {
return $m[1];
}
return '';
}
private function editionOrdinal($edition)
{
$edition = strtolower(trim((string)$edition));

View File

@@ -24,11 +24,15 @@ class ReferenceDispatchService
/** @var BookMetadataService */
private $bookMetadata;
/** @var BookCitationParser */
private $bookParser;
public function __construct()
{
$this->classifier = new ReferenceTypeClassifier(['use_llm' => true]);
$this->metadata = new ReferenceMetadataService();
$this->bookMetadata = new BookMetadataService();
$this->bookParser = new BookCitationParser();
}
/**
@@ -181,7 +185,9 @@ class ReferenceDispatchService
$summary = is_array($meta) ? $meta['crossref_summary'] : null;
$raw = is_array($summary) ? ($summary['raw'] ?? []) : [];
$hasMeta = is_array($meta)
&& (trim((string)$meta['title']) !== '' || trim((string)$meta['author']) !== '');
&& (trim((string)$meta['title']) !== '' || trim((string)$meta['author']) !== '')
// 图书著录里挂的 DOI 常常是被错误关联的期刊文章,题名对不上就不能拿来覆盖
&& $this->metaMatchesContent($meta, $content);
if ($hasMeta) {
$authorCitation = trim((string)$meta['author']);
@@ -217,6 +223,44 @@ class ReferenceDispatchService
Db::name('production_article_refer')->where('p_refer_id', $pReferId)->update($update);
}
/**
* DOI 抓回来的题名是否确实是这条著录说的那本书
*/
private function metaMatchesContent($meta, $content)
{
$title = $this->normalizeForMatch((string)$meta['title']);
if ($title === '') {
return false;
}
$blob = $this->normalizeForMatch($content);
if ($blob === '' || strpos($blob, $title) !== false) {
return true;
}
$words = array_filter(explode(' ', $title), function ($w) {
return strlen($w) > 3;
});
if (empty($words)) {
return false;
}
$hit = 0;
foreach ($words as $word) {
if (strpos($blob, $word) !== false) {
$hit++;
}
}
return ($hit / count($words)) >= 0.6;
}
private function normalizeForMatch($text)
{
$text = strtolower(trim((string)$text));
$text = preg_replace('/[^a-z0-9\x{4e00}-\x{9fff}\s]+/u', ' ', $text);
return trim(preg_replace('/\s+/u', ' ', $text));
}
/**
* 按书名/作者/年份/版次反查 ISBN顺带补齐缺失的出版社与出版年
*
@@ -299,50 +343,22 @@ class ReferenceDispatchService
}
/**
* 无 Crossref 时从原文尽量抽取 book 结构化字段
* 无可用 DOI 元数据时,从原文著录抽取 book 结构化字段
*/
private function parseBookFromContent($content)
{
$content = trim((string)$content);
$out = [
'author' => '',
'title' => '',
'joura' => '',
'dateno' => '',
'isbn' => '',
$parsed = $this->bookParser->parse($content);
$author = trim((string)$parsed['author']);
return [
'author' => $author !== '' ? rtrim($author, '.') . '.' : '',
'title' => trim((string)$parsed['title']),
'joura' => trim((string)$parsed['publisher']),
'dateno' => trim((string)$parsed['year']),
'isbn' => trim((string)$parsed['isbn']),
'is_ja' => 1,
];
if ($content === '') {
return $out;
}
if (preg_match('/\bISBN[:\s]*([\d\-Xx\s]+)/i', $content, $m)) {
$out['isbn'] = preg_replace('/\s+/', '-', trim($m[1]));
}
if (preg_match('/\b(19|20)\d{2}\b/', $content, $m)) {
$out['dateno'] = $m[0];
}
// Place: Publisher; Year → joura 取 Publisher
if (preg_match('/:\s*([^;]+);\s*(19|20)\d{2}/', $content, $m)) {
$out['joura'] = trim($m[1]);
} elseif (preg_match('/\b([A-Z][A-Za-z .&]+(?:Press|Publishing|Publisher|Books?))\b/i', $content, $m)) {
$out['joura'] = trim($m[1]);
}
// 作者. 标题. ... 简单拆分
$parts = preg_split('/\.\s+/', $content, 3);
if (is_array($parts) && count($parts) >= 2) {
$out['author'] = trim($parts[0]);
if (substr($out['author'], -1) !== '.') {
$out['author'] .= '.';
}
$out['title'] = trim(rtrim($parts[1], '.'));
}
return $out;
}
private function normalizeDoi($doi)