Compare commits

..

16 Commits

Author SHA1 Message Date
wangjinlei
f87057488b 参考文献的作者数变成6 2026-08-07 17:51:18 +08:00
wangjinlei
a1e151048c 参考文献格式--book 2026-08-07 16:04:59 +08:00
wangjinlei
175d2edc55 参考文献格式--book 2026-08-07 15:26:09 +08:00
wangjinlei
32b3233378 参考文献格式 2026-08-06 17:19:40 +08:00
wyn
16961465ac 参考文献模型校对换阿里云百炼,完善各种校对细节 2026-08-06 10:19:57 +08:00
wyn
bffac7755d 参考文献模型校对换阿里云百炼,完善各种校对细节 2026-08-06 10:14:36 +08:00
wangjinlei
5d40b8358c Merge remote-tracking branch 'origin/master' 2026-08-05 18:36:23 +08:00
wangjinlei
d72b16d9e8 参考文献格式 2026-08-05 18:36:17 +08:00
wyn
f4ebd10bfa Merge branch 'master' of https://git.nuttyreading.com/zm/tougao 2026-08-05 11:58:56 +08:00
wyn
1e7afcbeff 一处原文引用20多个参考文献优化 2026-08-05 11:58:36 +08:00
wangjinlei
2db6f170fe Merge remote-tracking branch 'origin/master' 2026-08-05 11:39:49 +08:00
wangjinlei
0aa4a82b48 小更新 2026-08-05 11:39:38 +08:00
wyn
32fb903c5d 一处原文引用20多个参考文献优化 2026-08-04 17:11:53 +08:00
wyn
485d7c8d97 Merge branch 'master' of https://git.nuttyreading.com/zm/tougao 2026-08-04 10:38:58 +08:00
wyn
97fa5ed432 专刊相关功能优化,官网和投稿系统期刊id不一致 2026-08-04 10:38:51 +08:00
wyn
33f1bdc729 官网专刊上传文件添加20M的限制 2026-08-04 10:36:47 +08:00
22 changed files with 4252 additions and 409 deletions

14
.env
View File

@@ -41,6 +41,14 @@ PROMOTION_LLM_TIMEOUT=30
PROMOTION_LLM_FALLBACK="We would like to cordially invite you to consider submitting a manuscript to {{journal_name}}."
PROMOTION_LLM_ADVISED_FALLBACK=""
; 参考文献「主题相关性」校对专用 LLM阿里云百炼留空则回退上方 PROMOTION_LLM_*
RELEVANCE_LLM_URL=https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
RELEVANCE_LLM_MODEL=qwen-plus
RELEVANCE_LLM_API_KEY=sk-ws-H.ELXPXXY.dXOB.MEYCIQDMu29bWkF-gis1wqWmVTwdVubXQkoGqpyvj7HQOmRTFQIhAM6yWxieiy_mpMqlmjJEndTISBWcU3SSI6KMV-qjr_Or
RELEVANCE_LLM_TIMEOUT=120
RELEVANCE_LLM_RETRIES=1
RELEVANCE_LLM_PER_REF_THRESHOLD=4
[unsubscribe]
UNSUBSCRIBE_SECRET="TMR Unsubscribe Secret create on 20260427"
UNSUBSCRIBE_BASE_URL=https://submission.tmrjournals.com/api/Unsubscribe/index
@@ -66,8 +74,7 @@ static_root="/home/wwwroot/api.tmrjournals.com/public"
[journal]
;官网服务器地址
base_url = http://journalapi.tmrjournals.com/public/index.php
;base_url =http://192.168.110.131/journal/public/index.php
journal_image_url = http://192.168.110.131/journal/public/
journal_image_url = https://www.tmrjournals.com/public/
[gpt]
api_key = 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
@@ -86,6 +93,9 @@ citation_chat_url = http://127.0.0.1:11434/v1/chat/completions
citation_chat_model = qwen2.5:7b
citation_chat_api_key =
citation_chat_timeout = 120
pubmed_email = 13662001490@126.com
pubmed_api_key = c6f752b40432eb6e4522f0ff219bb9ff7608
crossref_mailto = 13662001490@126.com
[expert_country]
chat_url_local = http://125.39.141.154:10002/v1/chat/completions

View File

@@ -874,8 +874,8 @@ class Preaccept extends Base
$title = trim((string)($summary['title'] ?? ''));
$jouraRaw = trim((string)($summary['joura'] ?? ''));
// 姓全写 + 名首字母,超过 3作者取前 3 个 + et al
$authorCitation = $svc->getAuthorsCitation($summary['raw'] ?? [], 3);
// 姓全写 + 名首字母,作者数超过 6取前 3 个 + et al
$authorCitation = $svc->getAuthorsCitation($summary['raw'] ?? []);
$dateno = trim((string)($summary['dateno'] ?? ''));
$doilink = trim((string)($summary['doilink'] ?? ''));
if ($doilink === '') {

View File

@@ -1005,12 +1005,10 @@ class Production extends Base
public function prgeAuthor($author)
{
$a = explode(',', $author);
if (count($a) < 7) {
return $author . '.';
} else {
return trim($a[0]) . ', ' . trim($a[1]) . ', ' . trim($a[2]) . ', et al.';
}
//作者不超过 6 个全部列出,超过则只列前 3 个加 et al
$formatted = \app\common\AuthorListFormatter::format($author);
return $formatted === '' ? '' : rtrim($formatted, '.') . '.';
}
@@ -2824,7 +2822,8 @@ class Production extends Base
];
// 匹配模式year;volume(number):pages
$pattern = '/(\d{4})\s*;\s*(\d+)(?:\(([^)]+)\))?(?:\s*:\s*([a-zA-Z0-9\u2013\u2014\-]+(?:\s*[\u2013\u2014\-]\s*[a-zA-Z0-9]+)?))?/';
// PCRE 不认 \uXXXX会当成字面量 u页码连接符须写成 \x{2013} 并加 u 修饰符
$pattern = '/(\d{4})\s*;\s*(\d+)(?:\(([^)]+)\))?(?:\s*:\s*([a-zA-Z0-9]+(?:\s*[\x{2013}\x{2014}\x{2212}-]\s*[a-zA-Z0-9]+)?))?/u';
if (preg_match($pattern, $referenceText, $matches)) {
@@ -2863,7 +2862,7 @@ class Production extends Base
}
// 提取卷号和期号(格式如 54(4) 或 54 (4)
if (preg_match('/(\d+)\s*\(?(\d*)\)?\s*:?\s*([a-zA-Z0-9\-]*)/', $referenceText, $volMatches)) {
if (preg_match('/(\d+)\s*\(?(\d*)\)?\s*:?\s*([a-zA-Z0-9\x{2013}\x{2014}\x{2212}-]*)/u', $referenceText, $volMatches)) {
if (isset($volMatches[1])) {
$result['volume'] = $volMatches[1];
}

View File

@@ -82,14 +82,8 @@ class References extends Base
if(!empty($aRefer['doilink'])){
$sAuthor = empty($aRefer['author']) ? '' : trim(trim($aRefer['author']),'.');
if(!empty($sAuthor)){
$aAuthor = explode(',', $sAuthor);
if(count($aAuthor) > 3){
$sAuthor = implode(',', array_slice($aAuthor, 0,3));
$sAuthor .= ', et al';
}
if(count($aAuthor) <= 3 ){
$sAuthor = implode(',', $aAuthor);
}
//作者不超过 6 个全部列出,超过则只列前 3 个加 et al
$sAuthor = \app\common\AuthorListFormatter::format($sAuthor);
}
//文章标题
$sTitle = empty($aRefer['title']) ? '' : trim(trim($aRefer['title']),'.');
@@ -116,14 +110,8 @@ class References extends Base
if($aRefer['refer_type'] == 'book'){
$sAuthor = empty($aRefer['author']) ? '' : trim(trim($aRefer['author']),'.');
if(!empty($sAuthor)){
$aAuthor = explode(',', $sAuthor);
if(count($aAuthor) > 3){
$sAuthor = implode(',', array_slice($aAuthor, 0,3));
$sAuthor .= ', et al';
}
if(count($aAuthor) <= 3 ){
$sAuthor = implode(',', $aAuthor);
}
//作者不超过 6 个全部列出,超过则只列前 3 个加 et al
$sAuthor = \app\common\AuthorListFormatter::format($sAuthor);
}
//文章标题
$sTitle = empty($aRefer['title']) ? '' : trim(trim($aRefer['title']),'.');
@@ -1214,7 +1202,7 @@ class References extends Base
$sSysMessagePrompt = '请完成以下任务:
1. 根据提供的DOI号查询该文献的AMA引用格式
2. 按照以下规则调整AMA引用格式
- 第三个作者名字后添加 et al.
- 作者不超过6个时全部列出超过6个时只保留前3个作者并在第三个作者名字后添加 et al.
- DOI前加上"Available at: "
- DOI信息格式调整为"https://doi.org/+真实DOI"替换真实DOI为文献实际DOI.
3. 严格按照以下JSON结构返回结果仅返回JSON数据不要额外文字,包含字段doilinkurl格式、title标题、author作者数组、joura出版社名称、dateno年;卷(期):起始页-终止页),is_ai_check(默认1)

View File

@@ -474,8 +474,14 @@ class Special extends Controller
$journal_info = $this->journal_obj->where('issn', $data['journal_issn'])->find();
$check_article = $this->article_obj->where('title',$data['title'])->find();
if($check_article){
return json(['code' => 1]);
return json(['code' => 1, 'msg' => 'A manuscript with the same title already exists. Please revise the title and try again.']);
}
if(isset($data['approval']) && $data['approval']==1 &&(!isset($data['approval_file'])||!$data['approval_file'])){
return json(['code' => 1, 'msg' => 'To help us proceed with your submission, please kindly upload the ethics approval document.']);
}else if((!isset($data['approval'])||$data['approval']!=1) && (!isset($data['approval_content']) || !$data['approval_content'])){
return json(['code' => 1, 'msg' => 'If an ethics approval document is not required, please kindly provide a brief explanation.']);
}
Db::startTrans();
@@ -495,6 +501,9 @@ class Special extends Controller
$inset_data['approval'] = $data['approval'] == 'true' ? 1 : 0;
$inset_data['abstrart'] = $data['abstrart'];
$inset_data['author_act'] = 1;
if(isset($data['approval']))$inset_data['approval'] = $data['approval'];
if(isset($data['approval_file']))$inset_data['approval_file'] = $data['approval_file'];
if(isset($data['approval_content']))$inset_data['approval_content'] = $data['approval_content'];
$inset_data['ctime'] = time();
$res = $this->article_obj->insertGetId($inset_data);
@@ -512,6 +521,7 @@ class Special extends Controller
$i['department'] = $v['department'];
$i['author_title'] = $v['title'];
$i['country'] = $v['country'];
$i['orcid'] = $v['orcid'];
$i['email'] = $v['email'];
$i['address'] = $v['address'];
$i['is_super'] = $v['isSuper'] == 'true' ? 1 : 0;
@@ -598,6 +608,53 @@ class Special extends Controller
// ];
// Queue::push('app\api\job\domail@fire',$sendEditor,'domail');
$iJournalFee = empty($journal_info['fee']) ? 0 : $journal_info['fee'];
$iNowFee = intval($iJournalFee * 100);
//收费地址
$sApc = empty($journal_info['apc_url']) ? '' : $journal_info['apc_url'];
//稿件号
$sArticleSn = $inset_data['accept_sn'];
//邮件主题
$sSubject = 'Manuscript Submission Confirmation '.'['.$sArticleSn.'] '.$journal_info['title'];
if ($iNowFee > 0) {
$tt1 = 'Thank you for submitting your manuscript entitled "' . $data['title'] . '". Your submission has been assigned the following tracking number: ' . $sArticleSn . '. We will be in touch again as soon as we have reached a decision. You may check on the status of this manuscript in the Submission System. Please quote the tracking number in any communication.<br/><br/>';
$tt1 .= 'The following information was acknowledged during the submission process: '.'<i>'.$journal_info['title'].'</i>'.' is an open access journal that charges a publication fee of '.$iJournalFee.' USD for accepted manuscripts (<a href="'.$sApc.'">click here</a> for details).<br/><br/>';
$tt1 .= 'This e-mail simply acknowledges receipt of your submission. If the editors decide for editorial reasons that the paper is unsuitable for publication in '.'<i>'.$journal_info['title'].'</i>'.', you will be informed as soon as possible.<br/><br/>';
$tt1 .= 'If you encounter any problems, please contact '.$journal_info['email'].'.<br/><br/>Thank you for choosing to submit your manuscript to '.'<i>'.$journal_info['title'].'</i>'.'.<br/><br/><br/>';
$tt1 .= 'Sincerely,<br/>Editorial Office<br/>';
$tt1 .= '<i>'.$journal_info['title'].'</i>' . '<br>';
$tt1 .= 'Email: ' . $journal_info['email'] . '<br>';
$tt1 .= 'Website: ' . $journal_info['website'] . '<br>';
$tt1 .= '<hr/>If you have any questions, please contact us: <br>';
$tt1 .= 'Head of publication ethics<br>Dr. Dan Chen<br>TMR Publishing Group Limited Company, Auckland, New Zealand<br>Email: publisher@tmrjournals.com';
} else {
$tt1 = 'Thank you for submitting your manuscript entitled "' . $data['title'] . '". Your submission has been assigned the following tracking number:' . $sArticleSn . '. We will be in touch again as soon as we have reached a decision. Please quote the tracking number in any communication. This e-mail simply acknowledges receipt of your submission. If the editors decide for editorial reasons that the paper is unsuitable for publication in ' . '<i>'.$journal_info['title'].'</i>' . ', you will be informed as soon as possible.<br><br><br>';
$tt1 .= 'You may check on the status of this manuscript in the Submission System. If you encounter any problems, please contact ' . $journal_info['email'] . '.<br><br>';
$tt1 .= 'Thank you for choosing to submit your manuscript to ' . '<i>'.$journal_info['title'].'</i>' . '.<br><br><br>';
$tt1 .= 'Sincerely,<br>Editorial Office<br>';
$tt1 .= '<a href="https://www.tmrjournals.com/draw_up.html?issn=' . $journal_info['issn'] . '">Subscribe to this journal</a><br>';
$tt1 .= '<i>'.$journal_info['title'].'</i>' . '<br>';
$tt1 .= 'Email: ' . $journal_info['email'] . '<br>';
$tt1 .= 'Website: ' . $journal_info['website'] . '<br>';
$tt1 .= '<hr/>If you have any questions, please contact us: <br>';
$tt1 .= 'Head of publication ethics<br>Dr. Dan Chen<br>TMR Publishing Group Limited Company, Auckland, New Zealand<br>Email: publisher@tmrjournals.com';
}
foreach ($data['authorList'] as $v) {
$cache_str = 'Dear Dr. ' . $v['firstname'] ." ".$v['lastname'] . ',<br><br>';
$maidata['email'] = $v['email'];
$maidata['title'] = $journal_info['title'];
$maidata['content'] = $cache_str . $tt1;
$maidata['temail'] = $journal_info['email'];
$maidata['tpassword'] = $journal_info['epassword'];
//给文章作者发送邮件主题调整 20250105 start
$maidata['subject'] = empty($sSubject) ? '' : $sSubject;//邮件主题
$maidata['article_id'] = empty($res) ? 0 : $res;//文章ID
// Queue::push('app\api\job\mail@fire', $maidata, "tmail");
Queue::push('app\api\job\SendAuthorEmail@fire', $maidata, "SendAuthorEmail");
//给文章作者发送邮件主题调整 20250105 end
}
//增加用户操作log
$log_data['user_id'] = $user_res['user_id'];
$log_data['type'] = 0;
@@ -923,11 +980,21 @@ class Special extends Controller
/**
* 上传文章的文件
* coverLetter / picturesAndTables / manuscirpt 限制不超过 20M
*/
public function up_file($type)
{
$file = request()->file($type);
if ($file) {
$limitTypes = ['coverLetter', 'picturesAndTables', 'manuscirpt'];
if (in_array($type, $limitTypes, true)) {
$maxSize = 20 * 1024 * 1024;
$fileInfo = $file->getInfo();
$fileSize = isset($fileInfo['size']) ? intval($fileInfo['size']) : 0;
if ($fileSize > $maxSize) {
return json(['code' => 1, 'msg' => 'File size cannot exceed 20MB']);
}
}
$info = $file->move(ROOT_PATH . 'public' . DS . $type);
if ($info) {
return json(['code' => 0, 'upurl' => str_replace("\\", "/", $info->getSaveName())]);
@@ -1046,7 +1113,7 @@ class Special extends Controller
$specials = isset($res['specials']) ? $res['specials'] : (isset($res['data']['specials']) ? $res['data']['specials'] : []);
foreach ($specials as $k => $v) {
unset($specials[$k]['abstract']);
$specials[$k]['icon'] = $specials[$k]['icon']?$journal_image_url."specialIcon/".$specials[$k]['icon']:"";
$specials[$k]['icon'] = $specials[$k]['icon']?$journal_image_url."journalicon/".$specials[$k]['icon']:"";
}
$re['specials'] = $specials;
$re['count'] = isset($res['data']['count']) ? intval($res['data']['count']) : 0;
@@ -2078,28 +2145,31 @@ class Special extends Controller
}
/**
* 上传专刊封面图(转发至 journal Special/up_icon_file
* 表单字段名:specialIcon
* 上传专刊封面图(转发至 journal master/Journal/up_file
* 入参表单字段兼容 specialIcon / journalicon转发官网时使用 journalicon
*/
public function up_icon_file()
{
$file = request()->file('specialIcon');
if (!$file) {
$file = request()->file('journalicon');
}
if (!$file) {
return jsonError('specialIcon is required');
}
$info = $file->getInfo();
$tmpPath = isset($info['tmp_name']) ? $info['tmp_name'] : '';
$originName = isset($info['name']) ? $info['name'] : 'specialIcon';
$originName = isset($info['name']) ? $info['name'] : 'journalicon';
$mime = isset($info['type']) && $info['type'] !== '' ? $info['type'] : 'application/octet-stream';
if ($tmpPath === '' || !is_file($tmpPath)) {
return jsonError('upload file invalid');
}
$base_url = Env::get('journal.base_url');
$url = rtrim($base_url, '/') . '/api/Special/up_icon_file';
$url = rtrim($base_url, '/') . '/master/Journal/up_file';
$postFields = [
'specialIcon' => new \CURLFile(realpath($tmpPath), $mime, $originName),
'journalicon' => new \CURLFile(realpath($tmpPath), $mime, $originName),
];
$ch = curl_init($url);
@@ -2229,5 +2299,17 @@ class Special extends Controller
return ['ok' => true, 'msg' => '', 'journal_id' => $journalId];
}
public function up_approval_file()
{
$file = request()->file('articleApproval');
if ($file) {
$info = $file->move(ROOT_PATH . 'public' . DS . 'articleApproval');
if ($info) {
return json(['code' => 0, 'upurl' => str_replace("\\", "/", $info->getSaveName())]);
} else {
return json(['code' => 1, 'msg' => $file->getError()]);
}
}
}
}

View File

@@ -258,7 +258,7 @@ class Ucenter extends Base{
}
$user_info=$this->user_obj->join("t_user_reviewer_info","t_user_reviewer_info.reviewer_id = t_user.user_id",'left')->where('t_user.user_id',$data['user_id'])->find();
$cv = $this->user_cv_obj->where('user_id',$data['user_id'])->where('state',0)->find();
if($user_info['realname']==''||$user_info['company']==''||$user_info['technical']==''||$user_info['major']==0||$user_info['field']==''||!$cv){
if($user_info['realname']==''||$user_info['company']==''||$user_info['technical']==''||$user_info['field']==''||!$cv){
$str = '';
switch($data['type']){
case 1:

View File

@@ -651,7 +651,8 @@ function formateAuthor($list){
if(isset($list['given_name'])||isset($list['surname'])){
$flag = $list['given_name']." ".$list['surname'];
}
else if (count($list)<=3){
//作者不超过 6 个全部列出,超过则只列前 3 个加 et al
else if (count($list)<=6){
foreach ($list as $v){
$flag .= $v['given_name']." ".$v['surname'].", ";
}
@@ -904,12 +905,10 @@ function formateJournal($fullname)
function prgeAuthor($author)
{
$a = explode(',', $author);
if (count($a) < 7) {
return $author . '.';
} else {
return trim($a[0]) . ', ' . trim($a[1]) . ', ' . trim($a[2]) . ', et al.';
}
//作者不超过 6 个全部列出,超过则只列前 3 个加 et al
$formatted = \app\common\AuthorListFormatter::format($author);
return $formatted === '' ? '' : rtrim($formatted, '.') . '.';
}

View File

@@ -0,0 +1,201 @@
<?php
namespace app\common;
/**
* 作者列表著录规则:作者数不超过 MAX_LISTED 时全部列出,超过时只列前 KEEP 位再加 et al。
*
* 单独抽出来是因为"数作者个数"并不能简单按逗号切:
* - 温哥华式 "Smith AB, Jones AC" 逗号 = 作者分隔
* - APA 式 "Smith, A. B., Jones, A. C." 逗号既分隔作者,也分隔姓与名
* - 机构作者 "Department of Health, Education, and Welfare" 逗号只是机构名的一部分
* 按逗号数会把 APA 的 3 位作者数成 6 位,凭空触发截断。
*
* 拿不准的一律少切(宁可多列几位,也不要把不该截的截掉)。
*/
class AuthorListFormatter
{
/** 全部列出的上限 */
const MAX_LISTED = 6;
/** 超过上限时保留的作者数 */
const KEEP = 3;
/**
* 按著录规则输出作者串
*/
public static function format($author, $max = self::MAX_LISTED, $keep = self::KEEP)
{
$author = self::normalize($author);
if ($author === '') {
return '';
}
// 已经是 "…, et al" 形态的(历史数据或上游截断过),保持原样,不再叠加
if (self::isTruncated($author)) {
return $author;
}
$units = self::split($author);
if (empty($units)) {
return $author;
}
$max = max(1, (int)$max);
$keep = min(max(1, (int)$keep), $max);
if (count($units) > $max) {
return implode(', ', array_slice($units, 0, $keep)) . ', et al';
}
return implode(', ', $units);
}
public static function countAuthors($author)
{
return count(self::split($author));
}
public static function isTruncated($author)
{
return (bool)preg_match('/\bet\s+al\.?\s*$/iu', (string)$author);
}
/**
* 拆成单个作者
*
* @return string[]
*/
public static function split($author)
{
$author = self::normalize($author);
$author = preg_replace('/[,;]?\s*\bet\s+al\.?\s*$/iu', '', $author);
$author = trim($author, " ,;.");
if ($author === '') {
return [];
}
if (strpos($author, ';') !== false) {
// 分号是无歧义的作者分隔符
$units = preg_split('/\s*;\s*/u', $author);
} else {
$text = $author;
// "A, B, and C" 里的 and/& 才是分隔符;没有逗号时它多半是机构名的一部分
// (如 "National Institute for Health and Care Excellence"),不能切
if (strpos($text, ',') !== false) {
$text = preg_replace('/\s*,?\s+(?:and|&)\s+/iu', ', ', $text);
}
$units = preg_split('/\s*,\s*/u', $text);
}
$units = array_values(array_filter(array_map(function ($u) {
return trim($u, " ,;");
}, (array)$units), 'strlen'));
$units = self::mergeGivenNames($units);
// 整串没有一个单元像人名,多半是带逗号的机构名,算一位作者
$hasPerson = false;
foreach ($units as $unit) {
if (self::looksLikePerson($unit)) {
$hasPerson = true;
break;
}
}
return $hasPerson ? $units : [$author];
}
/**
* 合并 APA 的 "姓, 名" —— "Smith", "A. B." → "Smith, A. B."
*
* @param string[] $units
* @return string[]
*/
private static function mergeGivenNames(array $units)
{
$merged = [];
foreach ($units as $unit) {
// 上一位作者已经带了名缩写,说明这一段是新作者的姓,不能再往上并
$last = empty($merged) ? '' : $merged[count($merged) - 1];
if ($last !== '' && !self::hasInitials($last) && self::looksLikeGivenName($unit)) {
$merged[count($merged) - 1] .= ', ' . $unit;
continue;
}
$merged[] = $unit;
}
return $merged;
}
private static function hasInitials($unit)
{
return (bool)preg_match('/\p{Lu}\./u', $unit) || (bool)preg_match('/\s\p{Lu}{1,4}$/u', $unit);
}
/**
* 是否只是名/缩写(属于上一个姓):每个词都是首字母大写,且至少有一个是"单字母 + 点"
* "A. B." / "H. Karl." → 是;"Jones AC" / "Bruce Alberts" → 否
*/
private static function looksLikeGivenName($unit)
{
$unit = trim((string)$unit);
if ($unit === '') {
return false;
}
// 末位作者的名常被上游去掉尾点,"K." 会变成 "K"
if (preg_match('/^\p{Lu}\.?$/u', $unit)) {
return true;
}
if (!preg_match('/\b\p{Lu}\./u', $unit)) {
return false;
}
foreach (preg_split('/\s+/u', $unit) as $word) {
$word = trim($word, " .");
if ($word === '') {
continue;
}
if (!preg_match('/^\p{Lu}[\p{L}]*$/u', $word)) {
return false;
}
}
return true;
}
/**
* 判定要偏严:机构名里的逗号片段("Department of Health" / "Education")不能算人名,
* 否则 "Department of Health, Education, and Welfare" 会被当成三位作者
*/
private static function looksLikePerson($unit)
{
$unit = trim(preg_replace('/\s+/u', ' ', str_replace(',', ' ', (string)$unit)));
if ($unit === '') {
return false;
}
// 中文姓名
if (preg_match('/^[\x{4e00}-\x{9fff}·]{2,10}$/u', $unit)) {
return true;
}
// 以缩写结尾:"Smith AB" / "van den Berg AB"
if (preg_match('/^\p{Lu}.*\s\p{Lu}{1,4}$/u', $unit)) {
return true;
}
// 带点的名缩写:"Smith A. B." / "Butcher H. Karl."
if (preg_match('/\b\p{Lu}\./u', $unit)) {
return true;
}
// 两个词的全名:"Bruce Alberts"
return (bool)preg_match('/^\p{Lu}[\p{L}\'\x{2019}\-]+\s+\p{Lu}[\p{L}\'\x{2019}\-]+$/u', $unit);
}
private static function normalize($author)
{
$author = trim((string)$author);
$author = str_replace(['', '', '', ' '], [',', ';', '.', ' '], $author);
$author = preg_replace('/\s+/u', ' ', $author);
return trim($author, " ,;");
}
}

View File

@@ -0,0 +1,554 @@
<?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);
// APA 格式:"作者 (年份). 书名 (第N版). 出版社." —— 年份括号是很硬的锚点,优先按它切
if ($this->takeApa($clean, $out)) {
if ($this->needsLlm($out)) {
$out = $this->refineByLlm($clean, $out);
}
return $out;
}
// 章节引用:"章节作者. 章节名. 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]);
}
// ------------------------------------------------------------------
// APA 格式
// ------------------------------------------------------------------
/**
* "Duffy, E., Hockenberry, M., & Gibbs, K. (2023). Wong's Nursing Care of
* Infants and Children (12th ed.). Elsevier."
*/
private function takeApa($text, array &$out)
{
if (!preg_match('/^(.{2,300}?)\s*\(\s*(\d{4})[a-z]?\s*\)\s*\.\s*(.+)$/su', $text, $m)) {
return false;
}
$author = preg_replace('/\(\s*(?:Ed|Eds|Editor|Editors)\.?\s*\)/iu', '', $m[1]);
$out['author'] = $this->normalizeApaAuthors(trim($author, " .,&"));
$out['year'] = $m[2];
// 章节引用:"章节名. In A. Editor (Ed.), 书名 (pp. 1-10). 出版社."
// 必须先认出 (Ed.) 再摘括注,否则 (Ed.) 会被当成版次括注先摘掉
$rest = trim($m[3]);
$containerPart = '';
if (preg_match('/^(.+?)[\.,]\s*\bIn\b\s+.+?\(\s*Eds?\.?\s*\)\s*,\s*(.+)$/isu', $rest, $cm)) {
$rest = trim($cm[1]);
$containerPart = trim($cm[2]);
}
$rest = $this->takeApaParentheticals($rest, $out);
if ($containerPart !== '') {
$containerPart = $this->takeApaParentheticals($containerPart, $out);
}
$segments = $this->apaSegments($containerPart !== '' ? $containerPart : $rest);
if (empty($segments)) {
return false;
}
// APA 的出版社在最后一段,其余都算书名
if (count($segments) >= 2) {
$out['publisher'] = array_pop($segments);
}
$mainTitle = $this->cleanTitle(implode('. ', $segments));
if ($containerPart !== '') {
$out['container'] = $mainTitle;
$chapter = $this->apaSegments($rest);
$out['title'] = $this->cleanTitle(implode('. ', $chapter));
} else {
$out['title'] = $mainTitle;
}
return $out['title'] !== '';
}
/**
* 摘掉 APA 的括注版次与页码:"(12th ed.)"、"(pp. 1-10)"、"(2nd ed., pp. 1-10)"
* 书名自带的括注(如 "(NIC)")不含 ed./pp.,会原样留下
*/
private function takeApaParentheticals($text, array &$out)
{
if (!preg_match_all('/\(([^)]*(?:\bedn?\.|\bedition\b|\bpp?\.)[^)]*)\)/iu', $text, $ms, PREG_SET_ORDER)) {
return $text;
}
foreach ($ms as $item) {
$inner = $item[1];
$edition = $this->matchEdition($inner);
if ($edition !== '' && $out['edition'] === '') {
$out['edition'] = $edition;
}
if ($out['pages'] === ''
&& preg_match('/\bpp?\.\s*([\dA-Za-z]+(?:\s*[-\x{2013}]\s*[\dA-Za-z]+)?)/iu', $inner, $pm)) {
$out['pages'] = preg_replace('/\s+/', '', $pm[1]);
}
$text = str_replace($item[0], ' ', $text);
}
return trim(preg_replace('/\s+/u', ' ', $text));
}
/**
* @return string[]
*/
private function apaSegments($text)
{
$segments = [];
foreach ($this->splitSegments($text) as $seg) {
$seg = trim($seg, " .,;");
if ($seg !== '') {
$segments[] = $seg;
}
}
return $segments;
}
/**
* APA 姓名表 → 温哥华式,与期刊那条链路和 References 的渲染方式保持一致
* "Duffy, E., Hockenberry, M., & Gibbs, K." → "Duffy E, Hockenberry M, Gibbs K"
*/
private function normalizeApaAuthors($author)
{
$author = trim((string)$author);
$author = preg_replace('/\s*&\s*/u', ', ', $author);
$author = preg_replace('/(\s*,\s*)+/u', ', ', $author);
if ($author === '') {
return '';
}
// 没有"姓, 名缩写"结构的(机构作者等)原样保留
if (!preg_match('/,\s*\p{Lu}[\p{L}]*\./u', $author)) {
return $author;
}
$tokens = preg_split('/\s*,\s*/u', $author);
if (!is_array($tokens) || count($tokens) < 2 || count($tokens) % 2 !== 0) {
return $author;
}
$names = [];
for ($i = 0; $i < count($tokens); $i += 2) {
$surname = trim($tokens[$i], " .");
$given = trim($tokens[$i + 1], " .");
if (!preg_match('/^\p{Lu}[\p{L}\'\-\s]*$/u', $surname)
|| !preg_match('/^\p{Lu}[\p{L}\.\s]*$/u', $given)) {
return $author;
}
$initials = '';
foreach (preg_split('/[\s\.]+/u', $given) as $word) {
if ($word !== '') {
$initials .= mb_strtoupper(mb_substr($word, 0, 1), 'UTF-8');
}
}
$names[] = $initials === '' ? $surname : $surname . ' ' . $initials;
}
return implode(', ', $names);
}
// ------------------------------------------------------------------
// 作者
// ------------------------------------------------------------------
/**
* 从头部切出作者,返回剩余部分
*/
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

@@ -0,0 +1,705 @@
<?php
namespace app\common;
/**
* 图书元数据反查:按 书名 + 作者 + 年份 + 版次 检索 ISBN / 出版社 / 出版年。
*
* 背景:绝大多数图书参考文献既没有 DOI拿不到 Crossref 的 ISBN 数组),
* 原文里也不会写 ISBN所以只靠"抽取"永远拿不到,必须按书目信息去反查。
*
* 数据源:
* - Open Library主源免密钥可直连先检索 work再拉 editions 按年份/版次/出版社挑具体版本
* - Google Books备源需 .env 配 google_books_api_key无密钥时该 IP 会被 429
*
* 环境变量:
* - book_isbn_lookup 0 关闭反查,默认开启
* - book_lookup_timeout 单次请求超时秒数,默认 8
* - google_books_api_key 配置后启用 Google Books 备源
*/
class BookMetadataService
{
/** @var int */
private $timeout = 8;
/** @var bool */
private $enabled = true;
/** @var string */
private $googleKey = '';
/** @var int 单条文献反查的总耗时上限(秒),防止队列被慢请求拖住 */
private $budget = 20;
/** @var float */
private $deadline = 0;
public function __construct(array $config = [])
{
$this->enabled = $this->envGet('book_isbn_lookup', '1') !== '0';
$this->googleKey = trim((string)$this->envGet('google_books_api_key', ''));
$timeout = intval($this->envGet('book_lookup_timeout', 0));
if ($timeout > 0) {
$this->timeout = max(3, $timeout);
}
$budget = intval($this->envGet('book_lookup_budget', 0));
if ($budget > 0) {
$this->budget = max(5, $budget);
}
if (isset($config['timeout'])) $this->timeout = max(3, intval($config['timeout']));
if (isset($config['enabled'])) $this->enabled = (bool)$config['enabled'];
if (isset($config['google_key'])) $this->googleKey = (string)$config['google_key'];
if (isset($config['budget'])) $this->budget = max(5, intval($config['budget']));
}
private function outOfBudget()
{
return $this->deadline > 0 && microtime(true) >= $this->deadline;
}
/**
* 反查 ISBN
*
* @param array $hints title / author / year / publisher / edition / content
* @return array ['isbn'=>'','publisher'=>'','year'=>'','title'=>'','source'=>'']
*/
public function resolve(array $hints)
{
$empty = ['isbn' => '', 'publisher' => '', 'year' => '', 'title' => '', 'source' => ''];
if (!$this->enabled) {
return $empty;
}
$hints = $this->normalizeHints($hints);
if ($hints['title'] === '') {
return $empty;
}
// 原文里直接写了 ISBN 的,优先采信,不用外网
$inText = $this->extractIsbnFromText($hints['content']);
if ($inText !== '') {
return array_merge($empty, ['isbn' => $inText, 'source' => 'text']);
}
$cacheKey = 'book_' . sha1(strtolower($hints['title'] . '|' . $hints['author'] . '|' . $hints['year'] . '|' . $hints['edition']));
$cached = $this->cacheGet($cacheKey, 30 * 86400);
if (is_array($cached) && array_key_exists('isbn', $cached)) {
return array_merge($empty, $cached);
}
$this->deadline = microtime(true) + $this->budget;
$result = $this->searchOpenLibrary($hints);
if ($result['isbn'] === '' && $this->googleKey !== '' && !$this->outOfBudget()) {
$result = $this->searchGoogleBooks($hints);
}
$this->deadline = 0;
$this->cacheSet($cacheKey, $result);
return $result;
}
/**
* 从参考文献原文抽取书目线索(书名 / 作者 / 年份 / 版次 / 出版社)
*/
public function extractHints($content, array $known = [])
{
$content = trim((string)$content);
$hints = [
'title' => trim((string)($known['title'] ?? '')),
'author' => trim((string)($known['author'] ?? '')),
'year' => trim((string)($known['year'] ?? '')),
'publisher' => trim((string)($known['publisher'] ?? '')),
'edition' => '',
'content' => $content,
];
if ($content === '') {
return $hints;
}
// 反查用规则解析即可,不为了取个 ISBN 再多花一次大模型调用
$parsed = (new BookCitationParser(['use_llm' => false]))->parse($content);
// 章节引用反查的是书,不是章节
if ($parsed['container'] !== '') {
$hints['title'] = $parsed['container'];
} elseif ($hints['title'] === '') {
$hints['title'] = $parsed['title'];
}
foreach (['author', 'publisher', 'edition'] as $field) {
if ($hints[$field] === '' && $parsed[$field] !== '') {
$hints[$field] = $parsed[$field];
}
}
if ($hints['year'] === '') {
$hints['year'] = $parsed['year'];
}
return $hints;
}
// ------------------------------------------------------------------
// Open Library
// ------------------------------------------------------------------
private function searchOpenLibrary(array $hints)
{
$empty = ['isbn' => '', 'publisher' => '', 'year' => '', 'title' => '', 'source' => ''];
$q = 'title:"' . $this->escapeSolr($hints['title']) . '"';
$surnames = $this->authorSurnames($hints['author']);
if (!empty($surnames)) {
$q .= ' author:' . $this->escapeSolr($surnames[0]);
}
$url = 'https://openlibrary.org/search.json?' . http_build_query([
'q' => $q,
'limit' => 5,
'fields' => 'key,title,author_name,first_publish_year,edition_count,isbn,publisher',
]);
$docs = $this->getJson($url);
$docs = isset($docs['docs']) && is_array($docs['docs']) ? $docs['docs'] : [];
if (empty($docs) && !empty($surnames) && !$this->outOfBudget()) {
// 作者写法不一致时(编者、机构作者)退回只按书名检索
$url = 'https://openlibrary.org/search.json?' . http_build_query([
'q' => 'title:"' . $this->escapeSolr($hints['title']) . '"',
'limit' => 5,
'fields' => 'key,title,author_name,first_publish_year,edition_count,isbn,publisher',
]);
$docs = $this->getJson($url);
$docs = isset($docs['docs']) && is_array($docs['docs']) ? $docs['docs'] : [];
}
// 同一本书常被拆成多个 work书名带不带副标题、换了出版社
// 只看第一个命中的 work 会漏掉真正对得上版次的那一版,所以候选放在一起比
$best = null;
$bestScore = -1;
$checked = 0;
foreach ($docs as $doc) {
if ($checked >= 3 || $this->outOfBudget()) {
break;
}
$docTitle = trim((string)($doc['title'] ?? ''));
if (!$this->titlesMatch($hints['title'], $docTitle)) {
continue;
}
// 章节引用里"作者"是章节作者、编者写法也各式各样,对不上时不直接放弃,
// 改为要求出版年精确命中且另有版次或出版社佐证才采信
$authorOk = $this->authorsMatch($surnames, $doc['author_name'] ?? []);
$key = trim((string)($doc['key'] ?? ''));
if ($key === '') {
continue;
}
$checked++;
$edition = $this->pickEdition($key, $hints);
if (!$authorOk && $edition['score'] < 70) {
continue;
}
if ($edition['isbn'] !== '' && $edition['score'] > $bestScore) {
$bestScore = $edition['score'];
$best = [
'isbn' => $edition['isbn'],
'publisher' => $edition['publisher'],
'year' => $edition['year'],
'title' => $docTitle,
'source' => 'openlibrary',
];
}
// 年份与版次都对上了,没有更好的可能,不用再翻其他 work
if ($bestScore >= 100) {
break;
}
}
return $best === null ? $empty : $best;
}
/**
* 在 work 的版本列表里挑最贴合引文的那一版。
* 年份与版次是硬条件(对不上直接淘汰),出版社和 ISBN-13 只作同分时的取舍,
* 否则会出现"出版社对上了就把 2020 年第 14 版当成 2006 年第 11 版"的错配。
*
* @return array 附带 score供跨 work 比较
*/
private function pickEdition($workKey, array $hints)
{
$empty = ['isbn' => '', 'publisher' => '', 'year' => '', 'score' => -1];
$entries = [];
for ($page = 0; $page < 2; $page++) {
$url = 'https://openlibrary.org' . $workKey . '/editions.json?limit=100&offset=' . ($page * 100);
$data = $this->getJson($url);
$batch = isset($data['entries']) && is_array($data['entries']) ? $data['entries'] : [];
if (empty($batch)) {
break;
}
$entries = array_merge($entries, $batch);
if (count($batch) < 100 || count($entries) >= intval($data['size'] ?? 0) || $this->outOfBudget()) {
break;
}
}
if (empty($entries)) {
return $empty;
}
$wantYear = intval($hints['year']);
$wantEdition = $this->editionOrdinal($hints['edition']);
$wantPublisher = $this->normalizeToken($hints['publisher']);
$hasConstraint = ($wantYear > 0 || $wantEdition > 0);
$best = null;
$bestScore = -1;
foreach ($entries as $entry) {
$isbn = $this->pickBestIsbn(array_merge(
is_array($entry['isbn_13'] ?? null) ? $entry['isbn_13'] : [],
is_array($entry['isbn_10'] ?? null) ? $entry['isbn_10'] : []
));
if ($isbn === '') {
continue;
}
$year = $this->parseYear((string)($entry['publish_date'] ?? ''));
$entryEdition = $this->editionOrdinal((string)($entry['edition_name'] ?? ''));
// 版次写明且不一致,一定不是同一版
if ($wantEdition > 0 && $entryEdition > 0 && $wantEdition !== $entryEdition) {
continue;
}
$editionMatched = ($wantEdition > 0 && $wantEdition === $entryEdition);
$score = 0;
if ($wantYear > 0) {
if ($year > 0) {
$diff = abs($wantYear - $year);
if ($diff === 0) {
$score += 60;
} elseif ($diff === 1) {
// 版权年与实际发行年常差一年
$score += 40;
} elseif ($diff <= 2 && $editionMatched) {
$score += 20;
} else {
continue;
}
} elseif (!$editionMatched) {
// 年份对不上又没有版次佐证,不敢认
continue;
}
}
if ($editionMatched) {
$score += 40;
}
if ($wantPublisher !== '') {
foreach ((array)($entry['publishers'] ?? []) as $p) {
if ($this->tokensOverlap($wantPublisher, $this->normalizeToken($p))) {
$score += 10;
break;
}
}
}
if (strlen($isbn) === 13) {
$score += 3;
}
if ($score > $bestScore) {
$bestScore = $score;
$best = [
'isbn' => $isbn,
'publisher' => $this->firstString($entry['publishers'] ?? []),
'year' => $year > 0 ? (string)$year : '',
'score' => $score,
];
}
}
if ($best === null) {
return $empty;
}
// 引文给了年份/版次,就必须真的对上其中之一
if ($hasConstraint && $bestScore < 40) {
return $empty;
}
// 引文什么线索都没给:只有单一版本的书才敢直接给 ISBN
if (!$hasConstraint && count($entries) > 3) {
return $empty;
}
return $best;
}
// ------------------------------------------------------------------
// Google Books备源
// ------------------------------------------------------------------
private function searchGoogleBooks(array $hints)
{
$empty = ['isbn' => '', 'publisher' => '', 'year' => '', 'title' => '', 'source' => ''];
$q = 'intitle:"' . $hints['title'] . '"';
$surnames = $this->authorSurnames($hints['author']);
if (!empty($surnames)) {
$q .= ' inauthor:' . $surnames[0];
}
$url = 'https://www.googleapis.com/books/v1/volumes?' . http_build_query([
'q' => $q,
'maxResults' => 5,
'key' => $this->googleKey,
]);
$data = $this->getJson($url);
$items = isset($data['items']) && is_array($data['items']) ? $data['items'] : [];
foreach ($items as $item) {
$info = isset($item['volumeInfo']) && is_array($item['volumeInfo']) ? $item['volumeInfo'] : [];
$title = trim((string)($info['title'] ?? ''));
if (!$this->titlesMatch($hints['title'], $title)) {
continue;
}
if (!$this->authorsMatch($surnames, $info['authors'] ?? [])) {
continue;
}
$year = $this->parseYear((string)($info['publishedDate'] ?? ''));
$wantYear = intval($hints['year']);
if ($wantYear > 0 && $year > 0 && abs($wantYear - $year) > 3) {
continue;
}
$candidates = [];
foreach ((array)($info['industryIdentifiers'] ?? []) as $id) {
$type = strtoupper((string)($id['type'] ?? ''));
if ($type === 'ISBN_13' || $type === 'ISBN_10') {
$candidates[] = (string)($id['identifier'] ?? '');
}
}
$isbn = $this->pickBestIsbn($candidates);
if ($isbn !== '') {
return [
'isbn' => $isbn,
'publisher' => trim((string)($info['publisher'] ?? '')),
'year' => $year > 0 ? (string)$year : '',
'title' => $title,
'source' => 'googlebooks',
];
}
}
return $empty;
}
// ------------------------------------------------------------------
// 解析与校验
// ------------------------------------------------------------------
private function normalizeHints(array $hints)
{
$out = [
'title' => trim((string)($hints['title'] ?? '')),
'author' => trim((string)($hints['author'] ?? '')),
'year' => trim((string)($hints['year'] ?? '')),
'publisher' => trim((string)($hints['publisher'] ?? '')),
'edition' => trim((string)($hints['edition'] ?? '')),
'content' => trim((string)($hints['content'] ?? '')),
];
$out['title'] = $this->cleanTitle($out['title']);
if (mb_strlen($out['title'], 'UTF-8') < 4) {
$out['title'] = '';
}
return $out;
}
private function cleanTitle($title)
{
$title = trim((string)$title);
$title = preg_replace('/\s+/u', ' ', $title);
// 去掉粘在书名后的版次、卷次、出版地
$title = preg_replace('/[\.,;]?\s*\b\d{1,2}(?:st|nd|rd|th)?\s*(?:ed\.?|edition|版)\b.*$/iu', '', $title);
$title = preg_replace('/[\.,;]?\s*\b(?:first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)\s+(?:ed\.?|edition)\b.*$/iu', '', $title);
return trim($title, " .,;:");
}
private function editionOrdinal($edition)
{
$edition = strtolower(trim((string)$edition));
if ($edition === '') {
return 0;
}
if (preg_match('/\d{1,2}/', $edition, $m)) {
return intval($m[0]);
}
$words = [
'first' => 1, 'second' => 2, 'third' => 3, 'fourth' => 4, 'fifth' => 5,
'sixth' => 6, 'seventh' => 7, 'eighth' => 8, 'ninth' => 9, 'tenth' => 10,
];
foreach ($words as $word => $num) {
if (strpos($edition, $word) !== false) {
return $num;
}
}
return 0;
}
private function parseYear($date)
{
return preg_match('/\b(1[5-9]\d{2}|20\d{2})\b/', (string)$date, $m) ? intval($m[1]) : 0;
}
/**
* @return string[]
*/
private function authorSurnames($author)
{
$author = trim((string)$author);
if ($author === '') {
return [];
}
$surnames = [];
foreach (preg_split('/[,;]+/u', $author) as $chunk) {
$chunk = trim(preg_replace('/\b(et al\.?|eds?\.?|editors?)\b/i', '', $chunk));
if (preg_match_all('/[A-Za-z][A-Za-z\'\-]{2,}/u', $chunk, $m)) {
$surnames[] = $m[0][0];
}
}
return array_values(array_unique($surnames));
}
private function authorsMatch(array $surnames, $candidates)
{
if (empty($surnames)) {
return true;
}
$blob = strtolower(is_array($candidates) ? implode(' ', $candidates) : (string)$candidates);
if ($blob === '') {
return true;
}
foreach ($surnames as $surname) {
if (strpos($blob, strtolower($surname)) !== false) {
return true;
}
}
return false;
}
private function titlesMatch($expected, $found)
{
$a = $this->normalizeToken($expected);
$b = $this->normalizeToken($found);
if ($a === '' || $b === '') {
return false;
}
if ($a === $b || strpos($a, $b) !== false || strpos($b, $a) !== false) {
return true;
}
// 中日韩书名逐字比较不可靠,只认包含关系
if (preg_match('/[\x{4e00}-\x{9fff}]/u', $expected . $found)) {
return false;
}
similar_text($a, $b, $pct);
return $pct >= 80;
}
private function normalizeToken($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));
}
private function tokensOverlap($a, $b)
{
if ($a === '' || $b === '') {
return false;
}
if (strpos($a, $b) !== false || strpos($b, $a) !== false) {
return true;
}
$wordsA = array_filter(explode(' ', $a), function ($w) {
return strlen($w) > 3;
});
foreach ($wordsA as $word) {
if (strpos($b, $word) !== false) {
return true;
}
}
return false;
}
/**
* ISBN-13 优先,且必须通过校验位;只有 ISBN-10 时换算成等价的 ISBN-13
*/
private function pickBestIsbn($list)
{
$list = is_array($list) ? $list : [$list];
$isbn10 = '';
foreach ($list as $raw) {
$isbn = strtoupper(preg_replace('/[^0-9Xx]/', '', (string)$raw));
if (!$this->isValidIsbn($isbn)) {
continue;
}
if (strlen($isbn) === 13) {
return $isbn;
}
if ($isbn10 === '') {
$isbn10 = $isbn;
}
}
return $isbn10 === '' ? '' : $this->isbn10To13($isbn10);
}
private function isbn10To13($isbn10)
{
$body = '978' . substr($isbn10, 0, 9);
$sum = 0;
for ($i = 0; $i < 12; $i++) {
$sum += intval($body[$i]) * (($i % 2 === 0) ? 1 : 3);
}
return $body . ((10 - $sum % 10) % 10);
}
private function isValidIsbn($isbn)
{
$len = strlen($isbn);
if ($len === 13) {
if (!preg_match('/^\d{13}$/', $isbn)) {
return false;
}
$sum = 0;
for ($i = 0; $i < 12; $i++) {
$sum += intval($isbn[$i]) * (($i % 2 === 0) ? 1 : 3);
}
return ((10 - $sum % 10) % 10) === intval($isbn[12]);
}
if ($len === 10) {
if (!preg_match('/^\d{9}[\dX]$/', $isbn)) {
return false;
}
$sum = 0;
for ($i = 0; $i < 9; $i++) {
$sum += intval($isbn[$i]) * (10 - $i);
}
$sum += ($isbn[9] === 'X') ? 10 : intval($isbn[9]);
return $sum % 11 === 0;
}
return false;
}
private function extractIsbnFromText($content)
{
if (!preg_match_all('/\bISBN(?:-1[03])?[:\s]*([0-9][0-9\-\s]{8,20}[0-9Xx])/i', (string)$content, $m)) {
return '';
}
return $this->pickBestIsbn($m[1]);
}
private function firstString($list)
{
foreach ((array)$list as $item) {
$item = trim((string)$item);
if ($item !== '' && strcasecmp($item, 'Other') !== 0) {
return $item;
}
}
return '';
}
private function escapeSolr($text)
{
return trim(preg_replace('/["\\\\+\-!(){}\[\]^~*?:\/]+/u', ' ', (string)$text));
}
// ------------------------------------------------------------------
// HTTP / 缓存
// ------------------------------------------------------------------
private function getJson($url)
{
$timeout = $this->timeout;
if ($this->deadline > 0) {
$left = (int)ceil($this->deadline - microtime(true));
if ($left <= 0) {
return [];
}
$timeout = min($timeout, $left);
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(5, $timeout));
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/json',
'User-Agent: TMRjournals-BookLookup/1.0 (' . $this->envGet('crossref_mailto', 'support@tmrjournals.com') . ')',
]);
$res = curl_exec($ch);
$code = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE));
curl_close($ch);
if ($code !== 200 || !is_string($res) || $res === '') {
return [];
}
$decoded = json_decode($res, true);
return is_array($decoded) ? $decoded : [];
}
private function envGet($key, $default = '')
{
if (!class_exists('\think\Env')) {
return $default;
}
return \think\Env::get($key, $default);
}
private function cacheDir()
{
return rtrim(ROOT_PATH, '/') . '/runtime/book_cache';
}
private function cacheGet($key, $ttlSeconds)
{
$file = $this->cacheDir() . '/' . $key . '.json';
if (!is_file($file)) {
return null;
}
$mtime = filemtime($file);
if (!$mtime || (time() - $mtime) > $ttlSeconds) {
return null;
}
return json_decode((string)@file_get_contents($file), true);
}
private function cacheSet($key, $value)
{
$dir = $this->cacheDir();
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
@file_put_contents($dir . '/' . $key . '.json', json_encode($value, JSON_UNESCAPED_UNICODE));
}
}

View File

@@ -399,14 +399,16 @@ class CrossrefService
}
/**
* 引用格式作者串:姓全写 + 名首字母,超过 $maxAuthors 个取前 N 个 + et al
* Smith JA, Jones B, Lee C, et al
* 引用格式作者串:姓全写 + 名首字母
* 著录规则:作者数 <= $maxAuthors 时全部列出,超过时只列前 $keep 个再加 et al
* 例7 个作者 → Smith JA, Jones B, Lee C, et al
*
* @param array $aDoiInfo Crossref message
* @param int $maxAuthors 最多展示作者数,超过则截断加 et al
* @param int $maxAuthors 全部列出的上限,超过则截断
* @param int $keep 截断后保留的作者数
* @return string
*/
public function getAuthorsCitation($aDoiInfo = [], $maxAuthors = 3)
public function getAuthorsCitation($aDoiInfo = [], $maxAuthors = 6, $keep = 3)
{
$list = [];
if (!empty($aDoiInfo['author'])) {
@@ -436,8 +438,9 @@ class CrossrefService
}
$maxAuthors = max(1, (int)$maxAuthors);
$keep = min(max(1, (int)$keep), $maxAuthors);
if (count($list) > $maxAuthors) {
$list = array_slice($list, 0, $maxAuthors);
$list = array_slice($list, 0, $keep);
return implode(', ', $list) . ', et al';
}
@@ -470,39 +473,52 @@ class CrossrefService
*/
public function getPublishYear($aDoiInfo = [])
{
if (!empty($aDoiInfo['issued']['date-parts'][0][0])) {
return (string)$aDoiInfo['issued']['date-parts'][0][0];
// 著录用的是期次年。issued 取线上/线下较早者,在线优先出版的文献会偏早一年,
// 因此先看 published-print再回退 issued。
$candidates = [
$aDoiInfo['published-print']['date-parts'][0][0] ?? null,
$aDoiInfo['journal-issue']['published-print']['date-parts'][0][0] ?? null,
$aDoiInfo['issued']['date-parts'][0][0] ?? null,
$aDoiInfo['published']['date-parts'][0][0] ?? null,
$aDoiInfo['published-online']['date-parts'][0][0] ?? null,
];
foreach ($candidates as $year) {
if (!empty($year)) {
return (string)$year;
}
}
return '';
}
/**
* 提取卷(期):起始页-终止页(格式:2024:10(2):100-120
* 著录用的年卷期页,格式 Year;Volume(Issue):Pages2024;10(2):100-120
*/
public function getVolumeIssuePages($aDoiInfo = [])
{
$parts = [];
$year = $this->getPublishYear($aDoiInfo);
if ($year) $parts[] = $year;
$volume = $aDoiInfo['volume'] ?? '';
$issue = $aDoiInfo['issue'] ?? '';
if ($volume) {
$parts[] = $volume . ($issue ? "({$issue})" : '');
$volume = trim((string)($aDoiInfo['volume'] ?? ''));
$issue = trim((string)($aDoiInfo['issue'] ?? ($aDoiInfo['journal-issue']['issue'] ?? '')));
if ($volume !== '') {
$volume .= $issue !== '' ? "({$issue})" : '';
}
$pageStart = $aDoiInfo['page']['start'] ?? ($aDoiInfo['first-page'] ?? '');
$pageEnd = $aDoiInfo['page']['end'] ?? ($aDoiInfo['last-page'] ?? '');
$pages = '';
if ($pageStart) {
$pages = $pageStart . ($pageEnd ? "-{$pageEnd}" : '');
} else {
$pages = $aDoiInfo['page'] ?? '';
// page 是 "100-120" 这样的字符串BMC/PLOS 等电子刊无连续页码,改用文章号
$pages = trim((string)($aDoiInfo['page'] ?? ''));
if ($pages === '') {
$pages = trim((string)($aDoiInfo['article-number'] ?? ''));
}
if ($pages) $parts[] = $pages;
return implode(':', $parts);
$tail = $volume;
if ($pages !== '') {
$tail = $tail !== '' ? $tail . ':' . $pages : $pages;
}
if ($year === '' || $tail === '') {
return $year !== '' ? $year : $tail;
}
return $year . ';' . $tail;
}
/**

View File

@@ -163,12 +163,9 @@ class JournalArticle
}
$sDoi = empty($v['doi']) ? '' : self::$sDoiUrl.$v['doi'];
//作者
$aAuthorInfo = empty($v['abbr']) ? [] : explode(', ', str_replace([', ',','], ', ', $v['abbr']));
if(count($aAuthorInfo) > 3){
$sAuthorInfo = implode(', ', array_slice($aAuthorInfo,0,3)).", et al.";
}else{
$sAuthorInfo = empty($aAuthorInfo) ? '' : implode(', ', $aAuthorInfo).'.';
}
//作者不超过 6 个全部列出,超过则只列前 3 个加 et al
$sAuthorInfo = empty($v['abbr']) ? '' : \app\common\AuthorListFormatter::format($v['abbr']);
$sAuthorInfo = $sAuthorInfo === '' ? '' : $sAuthorInfo.'.';
$sArticleInfo .= $i.'. Article Title: '.$v['title'].'<br>Author(s): '.$sAuthorInfo.'<br>Link or DOI: '.$sDoi.'<br><br>';
$i++;
}

View File

@@ -1,8 +1,6 @@
<?php
namespace app\common;
use think\Db;
use think\Env;
use app\common\CrossrefService;
class ProductionArticleRefer
{
@@ -87,60 +85,56 @@ class ProductionArticleRefer
}
//开始用crossref接口的方式处理数据
$doiNorm = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', $aRefer['refer_doi']);
$doiNorm = trim($doiNorm, " \t\n\r\0\x0B/");
//开始用 PubMed(优先)+Crossref(补全) 的方式处理数据
$oMeta = new ReferenceMetadataService();
$doiNorm = $oMeta->normalizeDoi($aRefer['refer_doi']);
$svc = new CrossrefService([
'mailto' => trim((string)Env::get('crossref_mailto', '')),
]);
$summary = $svc->fetchWorkSummary($doiNorm);
if ($summary !== null && !empty($summary['doi'])) {
$title = trim((string)($summary['title'] ?? ''));
$jouraRaw = trim((string)($summary['joura'] ?? ''));
// 姓全写 + 名首字母,超过 3 个作者取前 3 个 + et al
$authorCitation = $svc->getAuthorsCitation($summary['raw'] ?? [], 3);
// 元数据仍是中文时放弃本路径,改走下方 citation.doi.org(lang=en-US) 取英文著录
$meta = $oMeta->fetchByDoi($doiNorm);
if ($meta !== null && !$meta['has_cjk'] && trim((string)$meta['title']) !== '') {
$title = trim((string)$meta['title']);
$authorCitation = trim((string)$meta['author']);
// 英文优先兜底:若 CrossRef 结果的标题/期刊/作者仍含中日韩字符,
// 说明该 DOI 元数据是中文,放弃 CrossRef 路径,改走下方 citation.doi.org(lang=en-US)
$hasCjk = $svc->hasCjk($title) || $svc->hasCjk($jouraRaw) || $svc->hasCjk($authorCitation);
if (!$hasCjk) {
$update_a = [];
$dateno = trim((string)($summary['dateno'] ?? ''));
$doilink = trim((string)($summary['doilink'] ?? ''));
$update_a['title'] = $title;
$update_a['author'] = $authorCitation !== '' ? $authorCitation . '.' : '';
$update_a['joura'] = $jouraRaw;
$update_a['dateno'] = $dateno;
// CrossRef 的 type 最权威,据此确定参考文献类型,未命中回退 journal
$crossrefType = isset($summary['raw']['type']) ? $summary['raw']['type'] : '';
$mappedType = (new ReferenceTypeClassifier())->mapCrossrefType($crossrefType);
$update_a['refer_type'] = $mappedType !== '' ? $mappedType : "journal";
$update_a['is_ja'] = 1;
$update_a['doilink'] = $doilink;
$update_a['cs'] = 1;
$update_a['update_time'] = time();
$update_a['is_deal'] = 1;
$update_a = [];
$update_a['title'] = $title;
$update_a['author'] = $authorCitation !== '' ? $authorCitation . '.' : '';
$update_a['joura'] = trim((string)$meta['joura']);
$update_a['dateno'] = trim((string)$meta['dateno']);
$update_a['refer_type'] = $meta['type'] !== '' ? $meta['type'] : "journal";
$update_a['is_ja'] = 1;
$update_a['doilink'] = trim((string)$meta['doilink']);
$update_a['cs'] = 1;
$update_a['update_time'] = time();
$update_a['is_deal'] = 1;
try {
(new ReferenceReferAuthorService())->syncFromWorkSummary(
try {
$oReferAuthor = new ReferenceReferAuthorService();
if (is_array($meta['crossref_summary'])) {
// Crossref 带 ORCID作者明细优先用它
$oReferAuthor->syncFromWorkSummary(
$iPReferId,
$iPArticleId,
$doiNorm,
$summary
$meta['crossref_summary']
);
} catch (\Throwable $e) {
\think\Log::error(
'ProductionArticleRefer sync refer authors failed p_refer_id='
. $iPReferId . ' ' . $e->getMessage()
} else {
$oReferAuthor->syncFromReferAuthorField(
$iPReferId,
$iPArticleId,
$authorCitation
);
}
Db::name('production_article_refer')->where(['p_refer_id' => $iPReferId])->limit(1)->update($update_a);
return json_encode(['status' => 1,'msg' => 'Update successful']);
} catch (\Throwable $e) {
\think\Log::error(
'ProductionArticleRefer sync refer authors failed p_refer_id='
. $iPReferId . ' ' . $e->getMessage()
);
}
Db::name('production_article_refer')->where(['p_refer_id' => $iPReferId])->limit(1)->update($update_a);
return json_encode(['status' => 1,'msg' => 'Update successful']);
}
//结束---用crossref接口的方式处理数据
//结束---用 PubMed+Crossref 的方式处理数据

View File

@@ -48,6 +48,8 @@ class ProductionArticleReferLiteratureService
'abstract_text' => '',
'content_text' => '',
'mesh_terms' => '',
'pub_language' => '',
'pub_country' => '',
'refer_content_cleaned' => '',
'literature_pdf_url' => '',
'fetch_sources' => '',
@@ -63,6 +65,8 @@ class ProductionArticleReferLiteratureService
'abstract_text' => trim((string)($stored['abstract_text'] ?? '')),
'content_text' => trim((string)($stored['content_text'] ?? '')),
'mesh_terms' => trim((string)($stored['mesh_terms'] ?? '')),
'pub_language' => trim((string)($stored['pub_language'] ?? '')),
'pub_country' => trim((string)($stored['pub_country'] ?? '')),
'refer_content_cleaned' => trim((string)($stored['refer_content_cleaned'] ?? '')),
'literature_pdf_url' => trim((string)($stored['literature_pdf_url'] ?? '')),
'fetch_sources' => trim((string)($stored['fetch_sources'] ?? '')),
@@ -109,6 +113,8 @@ class ProductionArticleReferLiteratureService
'abstract_text' => (string)($data['abstract_text'] ?? ''),
'content_text' => (string)($data['content_text'] ?? ''),
'mesh_terms' => $this->formatMeshTerms($data['mesh_terms'] ?? ''),
'pub_language' => $this->clip(strtolower(trim((string)($data['pub_language'] ?? ''))), 16),
'pub_country' => $this->clip(strtolower(trim((string)($data['pub_country'] ?? ''))), 64),
'refer_content_cleaned' => (string)($data['refer_content_cleaned'] ?? ''),
'literature_pdf_url' => $this->clip((string)($data['literature_pdf_url'] ?? ''), 1024),
'fetch_sources' => $this->clip($this->formatSources($data['fetch_sources'] ?? ''), 255),

View File

@@ -18,13 +18,43 @@ class PubmedService
private $timeout = 20;
private $tool = 'tmrjournals';
private $email = '';
// NCBI 限流:无 key 时 3 次/秒,配 key 后 10 次/秒。多 worker 并行处理参考文献时必须配。
private $apiKey = '';
private $maxRetry = 3;
public function __construct(array $config = [])
{
$this->email = (string)$this->envGet('pubmed_email', '');
$this->apiKey = (string)$this->envGet('pubmed_api_key', '');
$tool = trim((string)$this->envGet('pubmed_tool', ''));
if ($tool !== '') $this->tool = $tool;
if (isset($config['base'])) $this->base = rtrim((string)$config['base'], '/') . '/';
if (isset($config['timeout'])) $this->timeout = max(5, intval($config['timeout']));
if (isset($config['tool'])) $this->tool = (string)$config['tool'];
if (isset($config['email'])) $this->email = (string)$config['email'];
if (!empty($config['tool'])) $this->tool = (string)$config['tool'];
if (!empty($config['email'])) $this->email = (string)$config['email'];
if (!empty($config['api_key'])) $this->apiKey = (string)$config['api_key'];
if (isset($config['max_retry'])) $this->maxRetry = max(1, intval($config['max_retry']));
}
private function envGet($key, $default = '')
{
if (!class_exists('\think\Env')) {
return $default;
}
return \think\Env::get($key, $default);
}
/**
* tool/email/api_key 是 NCBI 要求的调用方标识,缺失会被更严格限流
*/
private function commonParams(): array
{
$params = ['tool' => $this->tool, 'email' => $this->email];
if ($this->apiKey !== '') {
$params['api_key'] = $this->apiKey;
}
return $params;
}
/**
@@ -60,18 +90,18 @@ class PubmedService
$pmid = trim($pmid);
if ($pmid === '') return null;
// v2:解析结果新增 journal_iso_abbr / journal_medline_ta换 key 避免命中旧缓存
$cacheKey = 'pmid_v2_' . $pmid;
// v3:解析结果新增 authors / volume / issue / pages / doilanguage / journal_country / affiliations换 key 避免命中旧缓存
$cacheKey = 'pmid_v3_' . $pmid;
$cached = $this->cacheGet($cacheKey, 30 * 86400);
if (is_array($cached)) return $cached;
$url = $this->base . 'efetch.fcgi?' . http_build_query([
$url = $this->base . 'efetch.fcgi?' . http_build_query(array_merge([
'db' => 'pubmed',
'id' => $pmid,
'retmode' => 'xml',
'tool' => $this->tool,
'email' => $this->email,
]);
], $this->commonParams()));
$xml = $this->httpGet($url);
if (!is_string($xml) || trim($xml) === '') return null;
@@ -254,10 +284,40 @@ class PubmedService
}
}
// 文献语种PubMed 用三字母代码eng/chi/ger…一篇可有多个
$languages = [];
$langNodes = $xp->query('//PubmedArticle//Article//Language');
if ($langNodes) {
foreach ($langNodes as $n) {
$t = strtolower(trim($n->textContent));
if ($t !== '') $languages[] = $t;
}
}
$languages = array_values(array_unique($languages));
// 期刊出版国MedlineJournalInfo/Country非研究开展国仅作兜底
$journalCountry = $this->xpText($xp, '//PubmedArticle//MedlineJournalInfo//Country');
// 作者单位原文,用于推断研究开展国
$affiliations = [];
$affNodes = $xp->query('//PubmedArticle//AuthorList//Author//AffiliationInfo//Affiliation');
if ($affNodes) {
foreach ($affNodes as $n) {
$t = trim($n->textContent);
if ($t !== '') $affiliations[] = $t;
}
}
$affiliations = array_values(array_unique($affiliations));
if ($title === '' && $abstract === '') {
return null;
}
$doi = $this->xpText($xp, '//PubmedArticle//ArticleIdList/ArticleId[@IdType="doi"]');
if ($doi === '') {
$doi = $this->xpText($xp, '//PubmedArticle//ELocationID[@EIdType="doi"]');
}
return [
'title' => $title,
'abstract' => $abstract,
@@ -267,9 +327,139 @@ class PubmedService
'journal_iso_abbr' => $journalIsoAbbr,
'journal_medline_ta' => $journalMedlineTa,
'year' => $year,
'language' => isset($languages[0]) ? $languages[0] : '',
'languages' => $languages,
'journal_country' => $journalCountry,
'affiliations' => $affiliations,
'authors' => $this->parseAuthors($xp),
'volume' => $this->xpText($xp, '//PubmedArticle//JournalIssue//Volume'),
'issue' => $this->xpText($xp, '//PubmedArticle//JournalIssue//Issue'),
'pages' => $this->parsePagination($xp),
'doi' => $doi,
];
}
/**
* 作者列表LastName/ForeName/Initials机构作者用 CollectiveName
*/
private function parseAuthors(\DOMXPath $xp): array
{
$out = [];
$nodes = $xp->query('//PubmedArticle//AuthorList/Author');
if (!$nodes) {
return $out;
}
foreach ($nodes as $n) {
$family = $given = $initials = $collective = '';
foreach ($n->childNodes as $c) {
switch ($c->nodeName) {
case 'LastName':
$family = trim($c->textContent);
break;
case 'ForeName':
$given = trim($c->textContent);
break;
case 'Initials':
$initials = trim($c->textContent);
break;
case 'CollectiveName':
$collective = trim($c->textContent);
break;
}
}
if ($family === '' && $given === '' && $collective === '') {
continue;
}
$out[] = [
'family' => $family,
'given' => $given,
'initials' => $initials,
'collective' => $collective,
];
}
return $out;
}
/**
* 页码StartPage/EndPage → MedlinePgn → 电子刊文章号ELocationID pii
*
* MedlinePgn 用 NLM 缩写式尾页210-8 表示 210218展开逻辑由调用方处理。
*/
private function parsePagination(\DOMXPath $xp): string
{
$start = $this->xpText($xp, '//PubmedArticle//Pagination//StartPage');
if ($start !== '') {
$end = $this->xpText($xp, '//PubmedArticle//Pagination//EndPage');
return $end !== '' ? $start . '-' . $end : $start;
}
$medlinePgn = $this->xpText($xp, '//PubmedArticle//Pagination//MedlinePgn');
if ($medlinePgn !== '') {
return $medlinePgn;
}
// 无连续页码的电子刊PLOS 等以文章号著录pii 也可能是出版社流水号,故只认 e12345/12345
$pii = $this->xpText($xp, '//PubmedArticle//ELocationID[@EIdType="pii"]');
return preg_match('/^e?\d+$/i', $pii) === 1 ? $pii : '';
}
/**
* 引用格式作者串:姓全写 + 名首字母
* 著录规则:作者数 <= $maxAuthors 时全部列出,超过时只列前 $keep 个再加 et al
* 例7 个作者 → Smith JA, Jones B, Lee C, et al
*/
public function authorsCitation(array $authors, int $maxAuthors = 6, int $keep = 3): string
{
$list = [];
foreach ($authors as $a) {
$collective = trim((string)($a['collective'] ?? ''));
if ($collective !== '') {
$list[] = $collective;
continue;
}
$family = trim((string)($a['family'] ?? ''));
if ($family === '') {
continue;
}
$initials = trim((string)($a['initials'] ?? ''));
if ($initials === '') {
$initials = $this->givenToInitials((string)($a['given'] ?? ''));
}
$list[] = $initials !== '' ? $family . ' ' . $initials : $family;
}
if (empty($list)) {
return '';
}
$maxAuthors = max(1, $maxAuthors);
$keep = min(max(1, $keep), $maxAuthors);
if (count($list) > $maxAuthors) {
return implode(', ', array_slice($list, 0, $keep)) . ', et al';
}
return implode(', ', $list);
}
private function givenToInitials($given): string
{
$given = trim((string)$given);
if ($given === '') {
return '';
}
$parts = preg_split('/[\s\-\.]+/u', $given, -1, PREG_SPLIT_NO_EMPTY);
$initials = '';
foreach ($parts as $p) {
$first = mb_substr($p, 0, 1);
if ($first !== '') {
$initials .= mb_strtoupper($first);
}
}
return $initials;
}
private function xpText(\DOMXPath $xp, string $query): string
{
$n = $xp->query($query);

View File

@@ -3,7 +3,6 @@
namespace app\common;
use think\Db;
use think\Env;
use think\Queue;
/**
@@ -19,9 +18,21 @@ class ReferenceDispatchService
/** @var ReferenceTypeClassifier */
private $classifier;
/** @var ReferenceMetadataService */
private $metadata;
/** @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();
}
/**
@@ -72,23 +83,23 @@ class ReferenceDispatchService
return;
}
$crossref = new CrossrefService([
'mailto' => trim((string)Env::get('crossref_mailto', '')),
]);
$summary = null;
$crossrefType = '';
$meta = null;
$typeHint = '';
if (trim((string)$refer['refer_doi']) !== '') {
$doiNorm = $this->normalizeDoi($refer['refer_doi']);
if ($doiNorm !== '') {
$summary = $crossref->fetchWorkSummary($doiNorm);
if ($summary && !empty($summary['raw']['type'])) {
$crossrefType = (string)$summary['raw']['type'];
$meta = $this->metadata->fetchByDoi($doiNorm);
if (is_array($meta)) {
$typeHint = trim((string)$meta['crossref_type']);
// Crossref 无 type 但 PubMed 收录时,给分类器一个等价的 Crossref type
if ($typeHint === '' && $meta['type'] === ReferenceTypeClassifier::TYPE_JOURNAL) {
$typeHint = 'journal-article';
}
}
}
}
$typeInfo = $this->classifier->classify((string)$refer['refer_content'], $crossrefType);
$typeInfo = $this->classifier->classify((string)$refer['refer_content'], $typeHint);
$dispatchType = $this->classifier->normalizeDispatchType($typeInfo['type']);
Db::name('production_article_refer')->where('p_refer_id', $refer['p_refer_id'])->update([
@@ -99,7 +110,7 @@ class ReferenceDispatchService
switch ($dispatchType) {
case ReferenceTypeClassifier::TYPE_BOOK:
$this->processBookRefer($refer, $summary, $crossref);
$this->processBookRefer($refer, $meta);
break;
case ReferenceTypeClassifier::TYPE_OTHER:
$this->processOtherRefer($refer);
@@ -161,7 +172,7 @@ class ReferenceDispatchService
/**
* book结构化字段DOI 仅用于补数据
*/
private function processBookRefer(array $refer, $summary, CrossrefService $crossref)
private function processBookRefer(array $refer, $meta)
{
$pReferId = intval($refer['p_refer_id']);
$content = (string)$refer['refer_content'];
@@ -171,25 +182,37 @@ class ReferenceDispatchService
'update_time' => time(),
];
if (is_array($summary) && !empty($summary['raw'])) {
$raw = $summary['raw'];
$authorCitation = $crossref->getAuthorsCitation($raw, 3);
$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']) !== '')
// 图书著录里挂的 DOI 常常是被错误关联的期刊文章,题名对不上就不能拿来覆盖
&& $this->metaMatchesContent($meta, $content);
if ($hasMeta) {
$authorCitation = trim((string)$meta['author']);
$update['author'] = $authorCitation !== '' ? rtrim($authorCitation, '.') . '.' : '';
$update['title'] = trim((string)($summary['title'] ?? ''));
$update['joura'] = $this->extractBookPublisher($raw, $summary);
$update['dateno'] = $this->extractBookDateno($raw);
$isbn = $this->extractIsbnFromRaw($raw);
if ($isbn === '' && !empty($refer['refer_doi'])) {
$doi = $this->normalizeDoi($refer['refer_doi']);
$isbn = $doi !== '' ? 'https://doi.org/' . $doi : '';
}
$update['isbn'] = $isbn;
$update['title'] = trim((string)$meta['title']);
$update['joura'] = !empty($raw) ? $this->extractBookPublisher($raw, $summary) : '';
$update['dateno'] = !empty($raw) ? $this->extractBookDateno($raw) : '';
$update['isbn'] = !empty($raw) ? $this->extractIsbnFromRaw($raw) : '';
$update['is_ja'] = 1;
} else {
$parsed = $this->parseBookFromContent($content);
$update = array_merge($update, $parsed);
}
// 图书绝大多数没有 DOICrossref 和原文都给不出 ISBN按书目信息去外部书目库反查
if (trim((string)($update['isbn'] ?? '')) === '') {
$update = array_merge($update, $this->lookupBookIsbn($content, $update));
}
// 反查不到时退回 DOI 链接,保持 isbn 字段"可点开验证"的既有语义
if (trim((string)($update['isbn'] ?? '')) === '' && !empty($refer['refer_doi'])) {
$doi = $this->normalizeDoi($refer['refer_doi']);
$update['isbn'] = $doi !== '' ? 'https://doi.org/' . $doi : '';
}
$hasCore = trim((string)($update['author'] ?? '')) !== ''
&& trim((string)($update['title'] ?? '')) !== '';
$update['cs'] = $hasCore ? 1 : 0;
@@ -200,6 +223,81 @@ 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顺带补齐缺失的出版社与出版年
*
* @return array 只包含需要覆盖的字段
*/
private function lookupBookIsbn($content, array $update)
{
$known = [
'title' => (string)($update['title'] ?? ''),
'author' => (string)($update['author'] ?? ''),
'publisher' => (string)($update['joura'] ?? ''),
'year' => preg_match('/\b(19|20)\d{2}\b/', (string)($update['dateno'] ?? ''), $m) ? $m[0] : '',
];
try {
$hints = $this->bookMetadata->extractHints($content, $known);
$found = $this->bookMetadata->resolve($hints);
} catch (\Exception $e) {
\think\Log::write('book isbn lookup failed: ' . $e->getMessage(), 'error');
return [];
}
if (trim((string)$found['isbn']) === '') {
return [];
}
$patch = ['isbn' => trim((string)$found['isbn'])];
if (trim((string)($update['joura'] ?? '')) === '' && trim((string)$found['publisher']) !== '') {
$patch['joura'] = trim((string)$found['publisher']);
}
if (trim((string)($update['dateno'] ?? '')) === '' && trim((string)$found['year']) !== '') {
$patch['dateno'] = trim((string)$found['year']);
}
return $patch;
}
private function extractBookPublisher(array $raw, array $summary)
{
$publisher = trim((string)($raw['publisher'] ?? ''));
@@ -245,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)

View File

@@ -22,6 +22,8 @@ class ReferenceLiteratureFetchService
private $cmaJournal;
/** @var ReferenceCheckService */
private $refUtil;
/** @var \app\common\service\BibliographicMetaService */
private $bibMeta;
/** @var bool 预抓取阶段暂不调用 Yiigle 机构 API */
private $skipYiigle = false;
@@ -39,6 +41,7 @@ class ReferenceLiteratureFetchService
$this->unpaywall = new UnpaywallService();
$this->cmaJournal = new CmaJournalLiteratureService();
$this->refUtil = new ReferenceCheckService();
$this->bibMeta = new \app\common\service\BibliographicMetaService();
}
public function setSkipYiigle($skip = true)
@@ -304,6 +307,8 @@ class ReferenceLiteratureFetchService
$fetchLogs = [];
$pdfUrl = '';
$meshTerms = [];
$language = '';
$country = '';
// 0) 中华医学会期刊 DOIOpenAlex 中文摘要(可选 Yiigle 机构 API
if (CmaJournalLiteratureService::isCmaJournalDoi($doi)) {
@@ -380,6 +385,16 @@ class ReferenceLiteratureFetchService
if (!empty($pub['mesh_terms']) && is_array($pub['mesh_terms'])) {
$meshTerms = array_values(array_unique(array_merge($meshTerms, $pub['mesh_terms'])));
}
if ($language === '' && trim((string)($pub['language'] ?? '')) !== '') {
$language = strtolower(trim((string)$pub['language']));
}
if ($country === '') {
// 研究开展国以作者单位为准,期刊出版国只作兜底
$country = $this->bibMeta->detectCountry((array)($pub['affiliations'] ?? []));
if ($country === '' && trim((string)($pub['journal_country'] ?? '')) !== '') {
$country = $this->bibMeta->detectCountry((string)$pub['journal_country']);
}
}
$pubBlock = $this->formatPubmedBlock($pub, $doi);
if ($pubBlock !== '' && $abstract === '') {
$blocks[] = $pubBlock;
@@ -444,6 +459,8 @@ class ReferenceLiteratureFetchService
'raw_content' => $raw,
'pdf_url' => $pdfUrl,
'mesh_terms' => $meshTerms,
'language' => $language,
'country' => $country,
'sources' => array_values(array_unique($sources)),
'fetch_log' => trim('doi=' . $doi . '; sources=' . implode(',', $sources) . ($fetchLogs ? '; ' . implode('; ', $fetchLogs) : '')),
];
@@ -615,6 +632,8 @@ class ReferenceLiteratureFetchService
'raw_content' => '',
'pdf_url' => '',
'mesh_terms' => [],
'language' => '',
'country' => '',
'sources' => [],
'fetch_log' => (string)$reason,
];

View File

@@ -0,0 +1,263 @@
<?php
namespace app\common;
use think\Env;
/**
* 参考文献元数据聚合PubMed 优先Crossref 补全。
*
* 医学期刊的著录信息以 PubMedNLM 人工校订)更贴合期次著录:
* - 年份PubMed 取期次年Crossref 的 issued 是线上/线下较早者,在线优先出版会偏早一年
* - 期刊名PubMed 的 ISOAbbreviation / MedlineTA 就是排版要的规范缩写
* - 卷期页PubMed 覆盖完整Crossref 在 ahead-of-print 阶段常缺失
*
* Crossref 负责非 MEDLINE 收录文献的兜底,并提供 ORCID 作者明细与撤稿关系。
*/
class ReferenceMetadataService
{
/** @var PubmedService */
private $pubmed;
/** @var CrossrefService */
private $crossref;
/** @var ReferenceTypeClassifier */
private $classifier;
public function __construct()
{
$this->pubmed = new PubmedService();
$this->crossref = new CrossrefService([
'mailto' => trim((string)Env::get('crossref_mailto', '')),
]);
$this->classifier = new ReferenceTypeClassifier(['use_llm' => false]);
}
/**
* 按 DOI 聚合元数据
*
* @return array|null 两个源都查不到时返回 null
*/
public function fetchByDoi($doi)
{
$doi = $this->normalizeDoi($doi);
if ($doi === '') {
return null;
}
$pub = null;
try {
$pub = $this->pubmed->fetchByDoi($doi);
} catch (\Throwable $e) {
$pub = null;
}
if (!is_array($pub)) {
$pub = null;
}
$cr = $this->crossref->fetchWorkSummary($doi);
if (!is_array($cr)) {
$cr = null;
}
if ($pub === null && $cr === null) {
return null;
}
$sources = [];
if ($pub !== null) $sources[] = 'pubmed';
if ($cr !== null) $sources[] = 'crossref';
$title = $this->pickTitle($pub, $cr);
$joura = $this->pickJournal($pub, $cr);
$author = $this->pickAuthor($pub, $cr);
$dateno = $this->pickDateno($pub, $cr);
$doilink = trim((string)($cr['doilink'] ?? ''));
if ($doilink === '') {
$doilink = 'https://doi.org/' . $doi;
}
$crossrefType = trim((string)($cr['raw']['type'] ?? ''));
$type = $this->pickType($pub, $crossrefType);
$retract = $this->pickRetraction($pub, $cr);
return [
'doi' => $doi,
'pmid' => trim((string)($pub['pmid'] ?? '')),
'title' => $title,
'author' => $author,
'joura' => $joura,
'dateno' => $dateno,
'doilink' => $doilink,
'type' => $type,
'crossref_type' => $crossrefType,
'is_retracted' => $retract['is_retracted'],
'retract_reason' => $retract['reason'],
'has_cjk' => $this->crossref->hasCjk($title)
|| $this->crossref->hasCjk($joura)
|| $this->crossref->hasCjk($author),
'sources' => $sources,
'crossref_summary' => $cr,
'pubmed' => $pub,
];
}
/**
* 标题PubMed 优先。非英文原文在 PubMed 里是括号包裹的英译标题,去掉括号更贴合排版。
*/
private function pickTitle($pub, $cr)
{
$title = trim((string)($pub['title'] ?? ''));
if ($title !== '') {
$title = rtrim($title, '.');
if (preg_match('/^\[(.+)\]$/s', $title, $m)) {
$title = trim($m[1]);
}
return $title;
}
return trim((string)($cr['title'] ?? ''));
}
/**
* 期刊名PubMed 的规范缩写优先,其次 Crossref
*/
private function pickJournal($pub, $cr)
{
$abbr = trim((string)($pub['journal_iso_abbr'] ?? ''));
if ($abbr === '') {
$abbr = trim((string)($pub['journal_medline_ta'] ?? ''));
}
if ($abbr !== '') {
return $abbr;
}
return trim((string)($cr['joura'] ?? ''));
}
private function pickAuthor($pub, $cr)
{
$authors = isset($pub['authors']) && is_array($pub['authors']) ? $pub['authors'] : [];
if (!empty($authors)) {
$citation = $this->pubmed->authorsCitation($authors);
if ($citation !== '') {
return $citation;
}
}
return $this->crossref->getAuthorsCitation($cr['raw'] ?? []);
}
/**
* 卷期页PubMed 有卷或页时以 PubMed 为准,否则用 Crossref
*
* 格式与 citation.doi.org 路径一致Year;Volume(Issue):Pages
*/
private function pickDateno($pub, $cr)
{
$volume = trim((string)($pub['volume'] ?? ''));
$pages = $this->expandPageRange((string)($pub['pages'] ?? ''));
if ($volume === '' && $pages === '') {
return trim((string)($cr['dateno'] ?? ''));
}
if ($volume !== '') {
$issue = trim((string)($pub['issue'] ?? ''));
$volume .= $issue !== '' ? "({$issue})" : '';
}
$tail = $volume;
if ($pages !== '') {
$tail = $tail !== '' ? $tail . ':' . $pages : $pages;
}
$year = trim((string)($pub['year'] ?? ''));
if ($year === '' || $tail === '') {
return $year !== '' ? $year : $tail;
}
return $year . ';' . $tail;
}
/**
* 展开 NLM 缩写式尾页210-8 表示 210-218
*/
private function expandPageRange($pages)
{
$pages = trim((string)$pages);
if ($pages === '' || strpos($pages, '-') === false) {
return $pages;
}
$parts = explode('-', $pages, 2);
$start = trim($parts[0]);
$end = trim($parts[1]);
if ($start === '' || $end === '') {
return $pages;
}
if (ctype_digit($start) && ctype_digit($end) && strlen($end) < strlen($start)) {
$end = substr($start, 0, strlen($start) - strlen($end)) . $end;
}
return $start . '-' . $end;
}
/**
* 类型Crossref 的 type 最权威PubMed 的 publication_types 兜底
*/
private function pickType($pub, $crossrefType)
{
$mapped = $this->classifier->mapCrossrefType($crossrefType);
if ($mapped !== '') {
return $mapped;
}
$types = isset($pub['publication_types']) && is_array($pub['publication_types'])
? $pub['publication_types'] : [];
foreach ($types as $t) {
$t = strtolower(trim((string)$t));
if ($t === '') {
continue;
}
if (strpos($t, 'congress') !== false) {
return ReferenceTypeClassifier::TYPE_CONFERENCE;
}
if (strpos($t, 'journal article') !== false
|| strpos($t, 'review') !== false
|| strpos($t, 'clinical trial') !== false
|| strpos($t, 'meta-analysis') !== false
|| strpos($t, 'case reports') !== false
|| strpos($t, 'observational study') !== false
|| strpos($t, 'comparative study') !== false
|| strpos($t, 'multicenter study') !== false
|| strpos($t, 'editorial') !== false
|| strpos($t, 'letter') !== false) {
return ReferenceTypeClassifier::TYPE_JOURNAL;
}
}
return '';
}
private function pickRetraction($pub, $cr)
{
$types = isset($pub['publication_types']) && is_array($pub['publication_types'])
? $pub['publication_types'] : [];
foreach ($types as $t) {
if (stripos((string)$t, 'retract') !== false) {
return ['is_retracted' => 1, 'reason' => 'PubMed 标记:' . trim((string)$t)];
}
}
return [
'is_retracted' => !empty($cr['is_retracted']) ? 1 : 0,
'reason' => (string)($cr['retract_reason'] ?? ''),
];
}
public function normalizeDoi($doi)
{
$doi = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', trim((string)$doi));
return trim($doi, " \t\n\r\0\x0B/");
}
}

View File

@@ -454,66 +454,377 @@ class ReferenceRelevanceCheckService
if (!empty($fresh) && intval($fresh['status']) === self::RECORD_COMPLETED) {
return $this->formatReturnFromRow($fresh);
}
throw new \RuntimeException('Citation group leader not finished, reference_no=' . $leaderRefNo);
// 组长已完成时,允许任一 pending 成员继续跑剩余分块(断点续跑)
$leaderCompleted = false;
foreach ($groupRows as $gr) {
if (intval($gr['reference_no']) === $leaderRefNo
&& intval($gr['status']) === self::RECORD_COMPLETED) {
$leaderCompleted = true;
break;
}
}
if (!$leaderCompleted) {
// 组员不应单独跑;由 worker 跳过,等组长处理
throw new \RuntimeException(
'Citation group leader not finished, reference_no=' . $leaderRefNo,
9001
);
}
}
}
// 只跑尚未完成的编号;已落库的成功块不再重跑
$pendingGroupRows = [];
foreach ($groupRows as $gr) {
if (intval($gr['status']) !== self::RECORD_COMPLETED) {
$pendingGroupRows[] = $gr;
}
}
if (empty($pendingGroupRows)) {
$fresh = Db::name('article_reference_relevance_check_result')->where('id', $checkId)->find();
return $this->formatReturnFromRow(!empty($fresh) ? $fresh : $row);
}
DbReconnectHelper::release();
DbReconnectHelper::ensure();
$sectionText = $this->refUtil->resolveMainContentForJob($row);
$localContext = $this->resolveLocalContextForJob($row);
$citeGroupRefs = $this->resolveCiteGroupRefs($row, $groupRows);
$referText = $this->buildCombinedReferText($groupRows);
$referTypeMap = $this->resolveReferTypeMap($groupRows);
$citeGroupRefs = $this->buildCiteGroupRefsFromRows($pendingGroupRows);
$referText = $this->buildCombinedReferText($pendingGroupRows);
$referTypeMap = $this->resolveReferTypeMap($pendingGroupRows);
if ($skipLiteratureFetch) {
$literatureBundle = $this->resolveGroupLiteratureBundle($groupRows, $referTypeMap, false);
$literatureBundle = $this->resolveGroupLiteratureBundle($pendingGroupRows, $referTypeMap, false);
$abstractText = $literatureBundle['combined_text'];
} else {
// 优先读 t_production_article_refer摘要与清洗内容都为空时再抓取并回写 refer 表
DbReconnectHelper::release();
$literatureBundle = $this->resolveGroupLiteratureBundle($groupRows, $referTypeMap, true);
$literatureBundle = $this->resolveGroupLiteratureBundle($pendingGroupRows, $referTypeMap, true);
$abstractText = $literatureBundle['combined_text'];
DbReconnectHelper::ensure();
}
$noLiteratureEvidence = !$literatureBundle['has_verification_evidence'];
if ($noLiteratureEvidence && trim((string)$abstractText) === '') {
// 无摘要/全文时,退化为仅基于参考文献书目信息校对,并打标识供前端区分
$abstractText = "【文献书目信息(无摘要/全文)】\n" . $referText;
}
if ($sectionText === '' || $referText === '') {
$msg = 'Missing section content or refer_text';
$this->failGroupWithQueue($groupRows, $msg);
$this->failGroupWithQueue($pendingGroupRows, $msg);
throw new \RuntimeException($msg);
}
$llmService = new ReferenceRelevanceLlmService();
$appliedInCallback = 0;
$pArticleIdForHeartbeat = intval($row['p_article_id']);
$onChunkDone = function (array $part) use ($groupRows, &$appliedInCallback, $llmService, $pArticleIdForHeartbeat) {
DbReconnectHelper::ensure();
$n = $this->applyPartialGroupResults($groupRows, $part);
$appliedInCallback += $n;
if ($n > 0) {
$this->refreshGroupCombinedFields($groupRows, $llmService);
}
// 分块心跳:避免超大联合组单次跑超过僵尸阈值
if ($pArticleIdForHeartbeat > 0) {
Db::name('article_reference_relevance_check_batch')
->where('p_article_id', $pArticleIdForHeartbeat)
->where('batch_status', 1)
->update(['updated_at' => date('Y-m-d H:i:s')]);
}
$this->log(sprintf(
'relevance chunk persisted applied=%d',
$n
));
};
DbReconnectHelper::release();
$llm = (new ReferenceRelevanceLlmService())->checkRelevance(
$llm = $llmService->checkRelevance(
$sectionText,
$localContext,
$referText,
$abstractText,
$citeGroupRefs,
$referTypeMap
$referTypeMap,
$onChunkDone
);
DbReconnectHelper::ensure();
if (!empty($llm['request_failed']) || !$this->applyGroupResults($groupRows, $llm)) {
// 回调未覆盖的结果(单次调用或回调异常时)再补写一次
$applied = $appliedInCallback;
if (!empty($llm['results']) && is_array($llm['results'])) {
$applied += $this->applyPartialGroupResults($groupRows, $llm);
}
if ($applied <= 0 && !empty($llm['request_failed'])) {
$msg = isset($llm['reason']) ? (string)$llm['reason'] : 'LLM failed or empty results';
$this->failGroupWithQueue($groupRows, $msg);
// 逐篇/分块:单次 LLM 失败不整组标 failed留 pending 续跑
$this->resetIncompleteGroupToPending($groupRows, $msg);
throw new \RuntimeException($msg);
}
$combinedOverride = [];
if (!empty($llm['combined_locked'])) {
$combinedOverride = [
'combined_relevance_score' => $llm['combined_relevance_score'] ?? 0,
'combined_reason' => $llm['combined_reason'] ?? '',
'combined_author_comment' => $llm['combined_author_comment'] ?? '',
];
}
$this->refreshGroupCombinedFields($groupRows, $llmService, $combinedOverride);
if ($noLiteratureEvidence) {
$this->markGroupNoLiteratureEvidence($groupRows);
$completedRows = [];
foreach ($this->findCitationGroupRows($row) as $gr) {
if (intval($gr['status']) === self::RECORD_COMPLETED) {
$completedRows[] = $gr;
}
}
if (!empty($completedRows)) {
$this->markGroupNoLiteratureEvidence($completedRows);
}
}
$this->markGroupQueueRuntime($groupRows, self::QUEUE_COMPLETED);
// 仍未完成的编号保持 pending供后续重跑只补缺
$stillPending = $this->listIncompleteGroupRows($this->findCitationGroupRows($row));
foreach ($stillPending as $gr) {
$gid = intval($gr['id']);
if ($gid <= 0) {
continue;
}
if (intval($gr['status']) !== self::RECORD_PENDING) {
$this->updateRow($gid, [
'status' => self::RECORD_PENDING,
'error_msg' => isset($llm['reason']) ? mb_substr((string)$llm['reason'], 0, 512) : 'chunk pending retry',
]);
}
$this->markQueueRuntime($gid, self::QUEUE_PENDING);
}
$fresh = Db::name('article_reference_relevance_check_result')->where('id', $checkId)->find();
if (!empty($stillPending)) {
$this->log(sprintf(
'relevance group partial check_id=%d applied=%d still_pending=%d reason=%s',
$checkId,
$applied,
count($stillPending),
isset($llm['reason']) ? (string)$llm['reason'] : ''
));
}
return $this->formatReturnFromRow(!empty($fresh) ? $fresh : $row);
}
private function buildCiteGroupRefsFromRows(array $rows)
{
$nums = [];
foreach ($rows as $gr) {
$n = intval(isset($gr['reference_no']) ? $gr['reference_no'] : 0);
if ($n > 0) {
$nums[$n] = $n;
}
}
$list = array_values($nums);
sort($list, SORT_NUMERIC);
return implode(',', $list);
}
private function listIncompleteGroupRows(array $groupRows)
{
$out = [];
foreach ($groupRows as $gr) {
if (intval($gr['status']) !== self::RECORD_COMPLETED) {
$out[] = $gr;
}
}
return $out;
}
/**
* LLM 单次失败:未完成行保持/恢复 pending不标 failed。
*/
private function resetIncompleteGroupToPending(array $groupRows, $msg = '')
{
$msg = mb_substr(trim((string)$msg), 0, 512);
foreach ($this->listIncompleteGroupRows($groupRows) as $gr) {
$gid = intval($gr['id']);
if ($gid <= 0) {
continue;
}
$this->updateRow($gid, [
'status' => self::RECORD_PENDING,
'error_msg' => $msg,
]);
$this->markQueueRuntime($gid, self::QUEUE_PENDING);
}
}
public function isRelevanceLlmFailureMessage($msg)
{
$msg = (string)$msg;
if ($msg === '') {
return false;
}
$needles = [
'LLM curl error',
'LLM HTTP ',
'LLM failed',
'LLM split batch',
'LLM request failed',
'LLM not configured',
'LLM exception',
'LLM response',
'Operation timed out',
'0 bytes received',
];
foreach ($needles as $n) {
if (stripos($msg, $n) !== false) {
return true;
}
}
return false;
}
/**
* 仅落库 results 里出现的编号;返回成功写入条数。
*/
private function applyPartialGroupResults(array $groupRows, array $llmResponse)
{
$results = isset($llmResponse['results']) && is_array($llmResponse['results'])
? $llmResponse['results'] : [];
if (empty($results)) {
return 0;
}
$combinedScore = floatval(isset($llmResponse['combined_relevance_score']) ? $llmResponse['combined_relevance_score'] : 0);
$combinedReason = trim((string)(isset($llmResponse['combined_reason']) ? $llmResponse['combined_reason'] : ''));
$combinedAuthorComment = $this->resolveCombinedAuthorComment(
$llmResponse,
$combinedScore,
$combinedReason
);
$claimsJson = $this->encodeClaimsJson(isset($llmResponse['claims']) ? $llmResponse['claims'] : []);
$byRef = [];
foreach ($results as $item) {
if (!is_array($item)) {
continue;
}
$refNo = intval(isset($item['reference_no']) ? $item['reference_no'] : 0);
if ($refNo > 0) {
$byRef[$refNo] = $item;
}
}
if (empty($byRef)) {
return 0;
}
$applied = 0;
foreach ($groupRows as $gr) {
$refNo = intval($gr['reference_no']);
$gid = intval($gr['id']);
if ($refNo <= 0 || $gid <= 0 || !isset($byRef[$refNo])) {
continue;
}
$freshStatus = Db::name('article_reference_relevance_check_result')
->where('id', $gid)
->value('status');
// 已完成的不覆盖
if (intval($freshStatus) === self::RECORD_COMPLETED) {
continue;
}
$item = $byRef[$refNo];
$rowCombinedScore = $combinedScore > 0
? $combinedScore
: floatval(isset($item['combined_relevance_score']) ? $item['combined_relevance_score'] : $item['relevance_score']);
$rowCombinedReason = $combinedReason !== ''
? $combinedReason
: (string)(isset($item['combined_reason']) ? $item['combined_reason'] : $item['reason']);
$rowCombinedAuthorComment = $combinedAuthorComment !== ''
? $combinedAuthorComment
: $this->resolveCombinedAuthorComment($item, $rowCombinedScore, $rowCombinedReason);
$this->updateRow($gid, [
'is_relevant' => !empty($item['is_relevant']) ? 1 : 0,
'relevance_score' => floatval($item['relevance_score']),
'reason' => (string)$item['reason'],
'author_comment' => (string)($item['author_comment'] ?? ''),
'combined_relevance_score' => $rowCombinedScore,
'combined_reason' => $rowCombinedReason,
'combined_author_comment' => $rowCombinedAuthorComment,
'claims_json' => $claimsJson,
'status' => self::RECORD_COMPLETED,
'error_msg' => '',
]);
$this->markQueueRuntime($gid, self::QUEUE_COMPLETED);
$applied++;
}
return $applied;
}
/**
* 用组内已完成行重算并回写 combined_* / claims 对齐。
*/
private function refreshGroupCombinedFields(array $groupRows, ReferenceRelevanceLlmService $llmService = null, array $combinedOverride = [])
{
DbReconnectHelper::ensure();
$ids = [];
foreach ($groupRows as $gr) {
$id = intval(isset($gr['id']) ? $gr['id'] : 0);
if ($id > 0) {
$ids[] = $id;
}
}
if (empty($ids)) {
return;
}
$freshRows = Db::name('article_reference_relevance_check_result')
->whereIn('id', $ids)
->select();
$completed = [];
foreach ($freshRows as $gr) {
if (intval($gr['status']) === self::RECORD_COMPLETED) {
$completed[] = [
'reference_no' => intval($gr['reference_no']),
'relevance_score' => floatval($gr['relevance_score']),
'is_relevant' => intval($gr['is_relevant']),
'reason' => (string)$gr['reason'],
];
}
}
if (empty($completed)) {
return;
}
if (!empty($combinedOverride)) {
// 整组程序核验的结论不可由逐篇分数反推覆盖,直接沿用
$combinedScore = floatval($combinedOverride['combined_relevance_score'] ?? 0);
$combinedReason = (string)($combinedOverride['combined_reason'] ?? '');
$combinedAuthorComment = (string)($combinedOverride['combined_author_comment'] ?? '');
if ($combinedAuthorComment === '') {
$combinedAuthorComment = $llmService === null
? (new ReferenceRelevanceLlmService())->buildCombinedAuthorCommentFromReason($combinedScore, $combinedReason)
: $llmService->buildCombinedAuthorCommentFromReason($combinedScore, $combinedReason);
}
} else {
if ($llmService === null) {
$llmService = new ReferenceRelevanceLlmService();
}
$summary = $llmService->rebuildCombinedFromResults($completed);
$combinedScore = floatval($summary['combined_relevance_score']);
$combinedReason = (string)$summary['combined_reason'];
$combinedAuthorComment = (string)($summary['combined_author_comment'] ?? '');
}
foreach ($freshRows as $gr) {
if (intval($gr['status']) !== self::RECORD_COMPLETED) {
continue;
}
$this->updateRow(intval($gr['id']), [
'combined_relevance_score' => $combinedScore,
'combined_reason' => $combinedReason,
'combined_author_comment' => $combinedAuthorComment,
]);
}
}
public function getProgressByPArticleId($pArticleId)
{
$pArticleId = intval($pArticleId);
@@ -581,8 +892,13 @@ class ReferenceRelevanceCheckService
}
$claims = $this->decodeClaimsJson(isset($row['claims_json']) ? $row['claims_json'] : '');
$author_comment = $this->resolveAuthorCommentFromRow($row, $claims);
$author_comment = $author_comment?"<b>[文献 ".intval($row['reference_no'])."]: </b>".$author_comment:$author_comment;
$combined_author_comment = $this->resolveCombinedAuthorCommentFromRow($row);
$author_comment = $this->fillAuthorCommentFromCombined(
$this->resolveAuthorCommentFromRow($row, $claims),
$combined_author_comment,
floatval($row['combined_relevance_score'] ?? 0)
);
$author_comment = $author_comment ? ("<b>[文献 " . intval($row['reference_no']) . "]: </b>" . $author_comment) : $author_comment;
$g['records'][] = [
'check_id' => intval($row['id']),
'reference_no' => intval($row['reference_no']),
@@ -595,6 +911,7 @@ class ReferenceRelevanceCheckService
'author_comment' => $author_comment,
'combined_relevance_score' => floatval($row['combined_relevance_score']),
'combined_reason' => (string)$row['combined_reason'],
'combined_author_comment' => $combined_author_comment,
'cite_group_refs' => (string)$row['cite_group_refs'],
'claims' => $claims,
'evidence_mode' => ((string)($row['score_ceiling_trigger'] ?? '') === 'no_literature_evidence')
@@ -837,7 +1154,10 @@ class ReferenceRelevanceCheckService
/**
* 修复卡住队列:已完成但 queue 未同步;长时间 RUNNING 回退为待执行
*/
public function recoverQueueRowsForArticle($pArticleId)
/**
* @param bool $forceRunning 为 true 时立即回收本篇仍为 RUNNING 的 pending 行(断点续跑用),不再等 10 分钟
*/
public function recoverQueueRowsForArticle($pArticleId, $forceRunning = false)
{
$pArticleId = intval($pArticleId);
if ($pArticleId <= 0) {
@@ -853,17 +1173,19 @@ class ReferenceRelevanceCheckService
'queue_status' => self::QUEUE_COMPLETED,
'updated_at' => $now,
]);
// 仅回收“长时间未更新”的 RUNNING,避免多消费者并发时把正在执行的任务误回退成 PENDING
$runningStaleBefore = date('Y-m-d H:i:s', time() - 600);
Db::name('article_reference_relevance_check_result')
// 默认仅回收“长时间未更新”的 RUNNINGforce 时立刻回退,从卡死条继续
$runningQuery = Db::name('article_reference_relevance_check_result')
->where('p_article_id', $pArticleId)
->where('queue_status', self::QUEUE_RUNNING)
->where('status', self::RECORD_PENDING)
->where('updated_at', '<', $runningStaleBefore)
->update([
'queue_status' => self::QUEUE_PENDING,
'updated_at' => $now,
]);
->where('status', self::RECORD_PENDING);
if (!$forceRunning) {
$runningStaleBefore = date('Y-m-d H:i:s', time() - 600);
$runningQuery->where('updated_at', '<', $runningStaleBefore);
}
$runningQuery->update([
'queue_status' => self::QUEUE_PENDING,
'updated_at' => $now,
]);
$staleBefore = date('Y-m-d H:i:s', time() - 600);
Db::name('article_reference_relevance_check_result')
->where('p_article_id', $pArticleId)
@@ -1111,6 +1433,8 @@ class ReferenceRelevanceCheckService
'abstract_text' => $abstract,
'content_text' => $content,
'mesh_terms' => $mesh,
'pub_language' => $bundle['language'] ?? '',
'pub_country' => $bundle['country'] ?? '',
'refer_content_cleaned' => '',
'literature_pdf_url' => $pdfUrl,
'fetch_sources' => $bundle['sources'] ?? [],
@@ -1486,6 +1810,113 @@ class ReferenceRelevanceCheckService
return $this->findCitationGroupRows($row);
}
/**
* 联合引用组:仅组长(最小 reference_no负责跑 LLM组员等组长分块落库。
* 组长已完成但组内仍有 pending 时,允许任一 pending 行续跑剩余分块。
*/
public function shouldProcessRelevanceRowNow(array $row)
{
$groupRows = $this->findCitationGroupRows($row);
if (!$this->isCitationGroupCheck($groupRows)) {
return true;
}
$leaderRefNo = $this->resolveGroupLeaderRefNo($groupRows);
$currentRefNo = intval($row['reference_no']);
if ($currentRefNo === $leaderRefNo) {
return true;
}
foreach ($groupRows as $gr) {
if (intval($gr['reference_no']) !== $leaderRefNo) {
continue;
}
if (intval($gr['status']) === self::RECORD_COMPLETED) {
return true;
}
return false;
}
return true;
}
/**
* 组员无法处理时,若组长卡在 pending+RUNNING 则立即收回(单消费者下即僵尸)
*/
public function recoverStuckGroupLeaderForRow(array $row)
{
$groupRows = $this->findCitationGroupRows($row);
if (!$this->isCitationGroupCheck($groupRows)) {
return false;
}
$leaderRefNo = $this->resolveGroupLeaderRefNo($groupRows);
foreach ($groupRows as $gr) {
if (intval($gr['reference_no']) !== $leaderRefNo) {
continue;
}
if (intval($gr['status']) !== self::RECORD_PENDING) {
return false;
}
if (intval($gr['queue_status']) !== self::QUEUE_RUNNING) {
return false;
}
$leaderId = $this->resolveCheckRowId($gr);
if ($leaderId <= 0) {
return false;
}
$n = Db::name('article_reference_relevance_check_result')
->where('id', $leaderId)
->where('status', self::RECORD_PENDING)
->where('queue_status', self::QUEUE_RUNNING)
->update([
'queue_status' => self::QUEUE_PENDING,
'updated_at' => date('Y-m-d H:i:s'),
]);
return intval($n) > 0;
}
return false;
}
/**
* 收回卡死的 pending+RUNNING单消费者场景下 RUNNING 即异常)
*/
public function recoverStuckRunningPendingRows($pArticleId, $staleSeconds = 180)
{
$pArticleId = intval($pArticleId);
if ($pArticleId <= 0) {
return 0;
}
$staleBefore = date('Y-m-d H:i:s', time() - max(60, intval($staleSeconds)));
return Db::name('article_reference_relevance_check_result')
->where('p_article_id', $pArticleId)
->where('status', self::RECORD_PENDING)
->where('queue_status', self::QUEUE_RUNNING)
->where('updated_at', '<', $staleBefore)
->update([
'queue_status' => self::QUEUE_PENDING,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
/** 无时间门槛:收回本篇所有 pending+RUNNING无可领取行时用 */
public function recoverAllRunningPendingRows($pArticleId)
{
$pArticleId = intval($pArticleId);
if ($pArticleId <= 0) {
return 0;
}
return Db::name('article_reference_relevance_check_result')
->where('p_article_id', $pArticleId)
->where('status', self::RECORD_PENDING)
->where('queue_status', self::QUEUE_RUNNING)
->update([
'queue_status' => self::QUEUE_PENDING,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
private function findCitationGroupRows(array $row)
{
$amId = intval($row['am_id']);
@@ -1653,6 +2084,7 @@ class ReferenceRelevanceCheckService
'author_comment' => '',
'combined_relevance_score' => 0,
'combined_reason' => $reason,
'combined_author_comment' => (new ReferenceRelevanceLlmService())->buildCombinedAuthorCommentFromReason(0, $reason),
'claims_json' => '',
'status' => self::RECORD_COMPLETED,
'queue_status' => self::QUEUE_COMPLETED,
@@ -2072,6 +2504,8 @@ class ReferenceRelevanceCheckService
'abstract_text' => $abstract,
'content_text' => $contentText,
'mesh_terms' => $bundle['mesh_terms'] ?? [],
'pub_language' => $bundle['language'] ?? '',
'pub_country' => $bundle['country'] ?? '',
'refer_content_cleaned' => $cleaned,
'literature_pdf_url' => $pdfUrl,
'fetch_sources' => $bundle['sources'] ?? [],
@@ -2143,7 +2577,7 @@ class ReferenceRelevanceCheckService
if (!empty($pReferIds)) {
DbReconnectHelper::ensure();
$rows = Db::name('production_article_refer')
->field('p_refer_id,refer_type,isbn,refer_doi,doilink,refer_content,refer_frag')
->field('p_refer_id,refer_type,isbn,refer_doi,doilink,refer_content,refer_frag,joura,dateno')
->whereIn('p_refer_id', array_values($pReferIds))
->select();
foreach ($rows as $r) {
@@ -2151,42 +2585,142 @@ class ReferenceRelevanceCheckService
}
}
$metaById = [];
if (!empty($pReferIds)) {
try {
$metaRows = Db::name('production_article_refer_literature')
->field('p_refer_id,pub_language,pub_country,fetch_sources')
->whereIn('p_refer_id', array_values($pReferIds))
->select();
foreach ($metaRows ?: [] as $r) {
$metaById[intval($r['p_refer_id'])] = $r;
}
} catch (\Throwable $e) {
// 迁移未执行时字段不存在,语言/国别核验降级为「元数据缺失」
\think\Log::warning('resolveReferTypeMap literature meta unavailable: ' . $e->getMessage());
}
}
$map = [];
foreach ($groupRows as $gr) {
$refNo = intval($gr['reference_no']);
if ($refNo <= 0) {
continue;
}
$refer = $referById[intval($gr['p_refer_id'] ?? 0)] ?? [];
$type = $this->normalizeReferType($refer, trim((string)($gr['refer_text'] ?? '')));
$pReferId = intval($gr['p_refer_id'] ?? 0);
$refer = $referById[$pReferId] ?? [];
$meta = $metaById[$pReferId] ?? [];
$referText = trim((string)($gr['refer_text'] ?? ''));
$type = $this->normalizeReferType($refer, $referText);
$published = $this->resolvePublishedJournalStatus($type, $refer, $meta, $referText);
$map[$refNo] = [
'type' => $type,
'check_mode' => $type === 'book' ? 'bibliographic_inference' : ($type === 'journal' ? 'abstract_verification' : 'best_effort'),
'type' => $type,
'check_mode' => $type === 'book' ? 'bibliographic_inference' : ($type === 'journal' ? 'abstract_verification' : 'best_effort'),
'language' => strtolower(trim((string)($meta['pub_language'] ?? ''))),
'country' => strtolower(trim((string)($meta['pub_country'] ?? ''))),
'published' => $published['published'],
'published_evidence' => $published['evidence'],
];
}
return $map;
}
/**
* 「已发表期刊论文」判定:类型为 journal 尚不够,还需排除未发表状态,并有正式发表证据。
*
* @return array{published:?bool,evidence:string} published=true 已发表false 非已发表期刊null 证据不足
*/
private function resolvePublishedJournalStatus($type, array $refer, array $litMeta, $referTextFallback = '')
{
if ($type !== 'journal') {
return ['published' => false, 'evidence' => 'not_journal'];
}
$sourceText = trim((string)$referTextFallback);
foreach (['refer_content', 'refer_frag', 'joura', 'dateno'] as $field) {
$sourceText .= ' ' . trim((string)($refer[$field] ?? ''));
}
$sourceText = trim($sourceText);
// 明确未发表 / 投稿中 / 仅在刊前预印
if (preg_match('/\b(?:in\s+press|submitted|unpublished|manuscript|under\s+review|accepted\s+manuscript)\b/i', $sourceText)
|| preg_match('/待发表|已接收未刊|未发表|投稿中/u', $sourceText)) {
return ['published' => false, 'evidence' => 'unpublished_marker'];
}
$sources = strtolower(trim((string)($litMeta['fetch_sources'] ?? '')));
$hasPubmed = (strpos($sources, 'pubmed') !== false)
|| trim((string)($litMeta['pub_language'] ?? '')) !== '';
if ($hasPubmed) {
// PubMed 正式收录(非预印本站)即视为已发表期刊论文;预印本已在 normalizeReferType 剔为 other
return ['published' => true, 'evidence' => 'pubmed'];
}
$dateno = trim((string)($refer['dateno'] ?? ''));
$joura = trim((string)($refer['joura'] ?? ''));
$hasYear = (bool)preg_match('/(19|20)\d{2}/', $dateno !== '' ? $dateno : $sourceText);
// 正式卷期页2021:56:103200 / 1996:23(5):1024-1029 / 2024;23(1):12-18
$hasVolumePages = (bool)preg_match(
'/(?:^|[:;.\s])\d+\s*(?:\([^)]+\))?\s*[:]\s*\d+/',
$dateno !== '' ? $dateno : $sourceText
) || (bool)preg_match(
'/\d+\s*\(\d+\)\s*:\s*\d+/',
$dateno !== '' ? $dateno : $sourceText
);
$hasDoi = trim((string)($refer['refer_doi'] ?? '')) !== ''
|| trim((string)($refer['doilink'] ?? '')) !== ''
|| (bool)preg_match('/\b10\.\d{4,9}\//', $sourceText);
if ($joura !== '' && $hasYear && $hasVolumePages) {
return ['published' => true, 'evidence' => 'citation_volume'];
}
if ($joura !== '' && $hasYear && $hasDoi) {
// 有刊名+年份+DOI但无卷期页多为已正式发表的电子刊/文章编号,作已发表
return ['published' => true, 'evidence' => 'citation_doi'];
}
if ($hasYear && $hasVolumePages && $hasDoi) {
return ['published' => true, 'evidence' => 'citation_volume'];
}
// 仅有 journal 类型或仅有 DOI不足以断言“已发表”
return ['published' => null, 'evidence' => 'insufficient'];
}
/**
* 归一化文献类型:优先取 refer_type 字段,其次按 ISBN/DOI 规则兜底。
*/
private function normalizeReferType(array $refer, $referTextFallback = '')
{
$type = strtolower(trim((string)($refer['refer_type'] ?? '')));
if ($type === 'book' || $type === 'journal') {
return $type;
$sourceText = $referTextFallback;
foreach (['refer_content', 'refer_frag'] as $field) {
$sourceText .= ' ' . trim((string)($refer[$field] ?? ''));
}
$sourceText = trim($sourceText);
$sourceLower = strtolower($sourceText);
// 预印本/会议摘要等并非“已发表期刊”。
if (strpos($sourceLower, 'medrxiv') !== false
|| strpos($sourceLower, 'biorxiv') !== false
|| strpos($sourceLower, 'arxiv') !== false
|| preg_match('/\bpreprint\b/i', $sourceText)
|| preg_match('/\bconference\b|\bproceedings\b|\bsymposium\b|\bworkshop\b/i', $sourceText)) {
return 'other';
}
if ($type === 'book') {
return 'book';
}
if ($type === 'journal') {
// refer_type 标记为 journal 但命中预印本/会议关键词时,上面已提前返回 other。
return 'journal';
}
$isbn = trim((string)($refer['isbn'] ?? ''));
$hasDoi = trim((string)($refer['refer_doi'] ?? '')) !== ''
|| trim((string)($refer['doilink'] ?? '')) !== '';
$sourceText = $referTextFallback;
foreach (['refer_content', 'refer_frag'] as $field) {
$sourceText .= ' ' . trim((string)($refer[$field] ?? ''));
}
if ($isbn !== '' || preg_match('/\bISBN\b/i', $sourceText)
|| preg_match('/\b\d+(?:st|nd|rd|th)\s+ed\.?/i', $sourceText)) {
return 'book';
@@ -2208,6 +2742,11 @@ class ReferenceRelevanceCheckService
$combinedScore = floatval(isset($llmResponse['combined_relevance_score']) ? $llmResponse['combined_relevance_score'] : 0);
$combinedReason = trim((string)(isset($llmResponse['combined_reason']) ? $llmResponse['combined_reason'] : ''));
$combinedAuthorComment = $this->resolveCombinedAuthorComment(
$llmResponse,
$combinedScore,
$combinedReason
);
$claimsJson = $this->encodeClaimsJson(isset($llmResponse['claims']) ? $llmResponse['claims'] : []);
$byRef = [];
@@ -2239,6 +2778,9 @@ class ReferenceRelevanceCheckService
$rowCombinedReason = $combinedReason !== ''
? $combinedReason
: (string)(isset($item['combined_reason']) ? $item['combined_reason'] : $item['reason']);
$rowCombinedAuthorComment = $combinedAuthorComment !== ''
? $combinedAuthorComment
: $this->resolveCombinedAuthorComment($item, $rowCombinedScore, $rowCombinedReason);
$this->updateRow(intval($gr['id']), [
'is_relevant' => !empty($item['is_relevant']) ? 1 : 0,
'relevance_score' => floatval($item['relevance_score']),
@@ -2246,6 +2788,7 @@ class ReferenceRelevanceCheckService
'author_comment' => (string)($item['author_comment'] ?? ''),
'combined_relevance_score' => $rowCombinedScore,
'combined_reason' => $rowCombinedReason,
'combined_author_comment' => $rowCombinedAuthorComment,
'claims_json' => $claimsJson,
'status' => self::RECORD_COMPLETED,
'error_msg' => '',
@@ -2279,6 +2822,9 @@ class ReferenceRelevanceCheckService
if (isset($fields['combined_reason'])) {
$fields['combined_reason'] = mb_substr(trim((string)$fields['combined_reason']), 0, 2000);
}
if (isset($fields['combined_author_comment'])) {
$fields['combined_author_comment'] = mb_substr(trim((string)$fields['combined_author_comment']), 0, 2000);
}
if (isset($fields['claims_json'])) {
$fields['claims_json'] = mb_substr(trim((string)$fields['claims_json']), 0, 4000);
}
@@ -2300,6 +2846,7 @@ class ReferenceRelevanceCheckService
'author_comment' => '',
'combined_relevance_score' => 0,
'combined_reason' => '',
'combined_author_comment' => '',
'claims_json' => '',
'score_ceiling_trigger' => '',
'error_msg' => '',
@@ -2345,8 +2892,14 @@ class ReferenceRelevanceCheckService
}
}
$author_comment = $this->resolveAuthorCommentFromRow($row, $claims);
if($author_comment){
$reason = $reason . "\n" . $author_comment;
$combined_author_comment = $this->resolveCombinedAuthorCommentFromRow($row);
$author_comment = $this->fillAuthorCommentFromCombined(
$author_comment,
$combined_author_comment,
floatval($row['combined_relevance_score'] ?? 0)
);
if ($author_comment) {
$reason = $reason . "\n" . $author_comment;
}
return [
'check_id' => intval($row['id']),
@@ -2360,6 +2913,7 @@ class ReferenceRelevanceCheckService
'author_comment' => $author_comment,
'combined_relevance_score' => floatval($row['combined_relevance_score']),
'combined_reason' => (string)$row['combined_reason'],
'combined_author_comment' => $combined_author_comment,
'cite_group_refs' => (string)$row['cite_group_refs'],
'claims' => $claims,
'evidence_mode' => ((string)($row['score_ceiling_trigger'] ?? '') === 'no_literature_evidence')
@@ -2370,6 +2924,62 @@ class ReferenceRelevanceCheckService
];
}
/**
* 联合分 <= 0.65 且单条批注为空时,用组合批注回填展示用 author_comment。
*/
private function fillAuthorCommentFromCombined($authorComment, $combinedAuthorComment, $combinedScore)
{
$authorComment = trim((string)$authorComment);
if ($authorComment !== '') {
return $authorComment;
}
if (floatval($combinedScore) > 0.65 + 0.001) {
return '';
}
$combinedAuthorComment = trim((string)$combinedAuthorComment);
return $combinedAuthorComment;
}
/**
* 联合批注优先读库score<=0.65 且库空时按 author_comment 规则即时生成。
*/
private function resolveCombinedAuthorCommentFromRow(array $row)
{
$stored = '';
if (array_key_exists('combined_author_comment', $row)) {
$stored = trim((string)$row['combined_author_comment']);
}
$score = floatval($row['combined_relevance_score'] ?? 0);
if ($score > 0.65 + 0.001) {
return '';
}
if ($stored !== '') {
return $stored;
}
return (new ReferenceRelevanceLlmService())->buildCombinedAuthorCommentFromReason(
$score,
(string)($row['combined_reason'] ?? '')
);
}
/**
* 从 LLM/程序结果解析 combined_author_comment缺省时按联合分规则生成。
*/
private function resolveCombinedAuthorComment(array $payload, $combinedScore, $combinedReason)
{
$comment = trim((string)($payload['combined_author_comment'] ?? ''));
if ($comment !== '') {
return $comment;
}
return (new ReferenceRelevanceLlmService())->buildCombinedAuthorCommentFromReason(
floatval($combinedScore),
(string)$combinedReason
);
}
/**
* @param array|string $claims
*/

View File

@@ -143,7 +143,11 @@ class ReferenceTypeClassifier
$hasDoi = (bool)preg_match('/\bdoi:\s*10\./i', $text) || (bool)preg_match('#doi\.org/#i', $text);
$hasUrl = (bool)preg_match('#https?://#i', $text);
// 期刊卷期页结构,如 2020;382(8):727-733 或 2020;10:100
$hasJournalVol = (bool)preg_match('/\b(19|20)\d{2}\s*[;:]\s*\d+\s*(\(\d+\))?\s*:\s*[A-Za-z]?\d+/', $text);
// 年份与卷号之间容忍多余标点与全角符号(作者原文常见 "2026;, 44(5): 24-31"
$hasJournalVol = (bool)preg_match(
'/\b(19|20)\d{2}[\s;:,]+\d+\s*(\(\s*[^)]{1,12}\))?\s*[:]\s*[A-Za-z]?\d+/u',
$text
);
// 1) 学位论文
if (preg_match('/\[(ph\.?d\.?|master(\'s)?|doctoral|masters)?\s*(thesis|dissertation)\]/i', $text)
@@ -161,11 +165,13 @@ class ReferenceTypeClassifier
}
// 3) 网页 / 在线资源
if (preg_match('/\[(internet|online)\]/i', $text)
// "Available at:"/"Accessed" 同样出现在带 DOI 的期刊著录里(本系统自己的输出就带这个后缀),
// 故这些短语只在既无 DOI 也无卷期页结构时才作为网页依据
$hasWebPhrase = preg_match('/\[(internet|online)\]/i', $text)
|| preg_match('/\baccessed\b/i', $text)
|| preg_match('/\bavailable\s+(from|at)\b/i', $text)
|| preg_match('/\bcited\s+(19|20)\d{2}/i', $text)
|| ($hasUrl && !$hasDoi && !$hasJournalVol)) {
|| preg_match('/\bcited\s+(19|20)\d{2}/i', $text);
if (($hasWebPhrase || $hasUrl) && !$hasDoi && !$hasJournalVol) {
return ['type' => self::TYPE_WEB, 'confidence' => 0.8];
}

View File

@@ -9,6 +9,7 @@ use app\common\ReferenceRelevanceCheckService;
/**
* RabbitMQ 消费(队列 reference_check / ref_check.article
* 全局文章串行,文章内 reference_no 升序链式逐条「主题相关性」校对。
* 支持断点续跑:已完成条跳过,从卡死的 pending 条继续。
*/
class ReferenceCheckArticleWorker
{
@@ -17,6 +18,9 @@ class ReferenceCheckArticleWorker
const BATCH_DONE = 2;
const BATCH_PARTIAL_FAILED = 3;
/** 批次/心跳超时:超过该秒数无 updated_at 更新视为僵尸,可抢占续跑 */
const BATCH_STALE_SECONDS = 1200;
/** @var ReferenceRelevanceCheckService */
private $svc;
@@ -36,6 +40,13 @@ class ReferenceCheckArticleWorker
return;
}
// 先释放其它文章上的僵尸 RUNNING避免全局串行永久堵死
try {
$this->recoverStaleForeignBatches($batchId);
} catch (\Throwable $e) {
$this->svc->log('ReferenceCheckArticleWorker recoverStaleForeignBatches err=' . $e->getMessage());
}
if (!$this->canStartArticleWork($batchId)) {
$this->svc->log('ReferenceCheckArticleWorker defer batch_id=' . $batchId . ' other article running');
(new ReferenceCheckMqPublisher())->publishArticleStart(
@@ -47,95 +58,262 @@ class ReferenceCheckArticleWorker
return;
}
if (!$this->claimBatch($batchId)) {
$batch = $this->getBatch($batchId);
// 已被其他消费者领取或已结束,当前消息直接跳过,避免同批次并发重复执行
if (empty($batch)
|| intval($batch['batch_status']) === self::BATCH_RUNNING
|| intval($batch['batch_status']) === self::BATCH_DONE
|| intval($batch['batch_status']) === self::BATCH_PARTIAL_FAILED) {
return;
$claim = $this->claimOrResumeBatch($batchId);
if ($claim === 'skip') {
return;
}
$resume = ($claim === 'resume');
$owned = true;
$finished = false;
$attemptedCheckIds = [];
$idleRecovered = false;
try {
// 续跑时强制把卡死行收回 pending已完成行不动
$this->svc->recoverQueueRowsForArticle($pArticleId, $resume);
if ($trigger !== 'recheck_pending_only'
&& ReferenceRelevanceCheckService::PREPARE_LITERATURE_BEFORE_CHECK) {
$this->svc->prepareLiteratureContentByArticle($pArticleId);
}
$this->svc->log(
'ReferenceCheckArticleWorker start p_article_id=' . $pArticleId
. ' batch_id=' . $batchId
. ($resume ? ' resume=1' : '')
);
while (true) {
// 组长卡在 RUNNING 时先收回,避免 worker 误跑组员报 leader not finished
$this->svc->recoverStuckRunningPendingRows($pArticleId, 180);
$row = $this->fetchNextPendingRow($pArticleId);
if (empty($row)) {
if (!$idleRecovered) {
$n = $this->svc->recoverAllRunningPendingRows($pArticleId);
if ($n > 0) {
$idleRecovered = true;
$this->svc->log(
'ReferenceCheckArticleWorker recovered ' . $n . ' running pending rows, retry fetch'
);
continue;
}
}
break;
}
$idleRecovered = false;
$checkId = $this->svc->resolveCheckRowId($row);
if ($checkId <= 0) {
continue;
}
if (!$this->svc->shouldProcessRelevanceRowNow($row)) {
if ($this->svc->recoverStuckGroupLeaderForRow($row)) {
$this->svc->log(
'ReferenceCheckArticleWorker reset stuck group leader for ref='
. intval($row['reference_no'])
);
continue;
}
$this->svc->log(
'ReferenceCheckArticleWorker skip non-leader check_id=' . $checkId
. ' ref=' . intval($row['reference_no'])
);
continue;
}
// 同一批消息内每个 check_id 只尝试一次,避免大联合组部分成功后死循环
if (isset($attemptedCheckIds[$checkId])) {
$this->svc->log('ReferenceCheckArticleWorker stop re-entry check_id=' . $checkId);
break;
}
$attemptedCheckIds[$checkId] = true;
$this->processOneRow($checkId, $row, $trigger === 'recheck_pending_only');
// 每条结束后刷新批次心跳,长文不会被误判为僵尸
$this->touchBatch($batchId);
}
$stats = $this->summarizeArticleCheckStats($pArticleId);
if (intval($stats['pending']) > 0) {
// 分块落库后仍有缺口:回 WAITING 再投递,下轮只补 pending
Db::name('article_reference_relevance_check_batch')->where('id', intval($batchId))->update([
'batch_status' => self::BATCH_WAITING,
'done_count' => intval($stats['done']),
'failed_count' => intval($stats['failed']),
'updated_at' => date('Y-m-d H:i:s'),
]);
$finished = true;
$this->svc->log(
'ReferenceCheckArticleWorker defer incomplete p_article_id=' . $pArticleId
. ' batch_id=' . $batchId
. ' pending=' . $stats['pending']
. ' done=' . $stats['done']
);
(new ReferenceCheckMqPublisher())->publishArticleStart($pArticleId, $batchId, $trigger);
} else {
$this->finalizeBatch($batchId, $stats['done'], $stats['failed'], $stats['total']);
$finished = true;
$this->svc->log(
'ReferenceCheckArticleWorker done p_article_id=' . $pArticleId
. ' batch_id=' . $batchId
. ' done=' . $stats['done']
. ' failed=' . $stats['failed']
);
$this->publishNextWaitingBatch();
}
} catch (\Throwable $e) {
// 异常不 finalize保持 RUNNING靠心跳超时后由后续消息断点续跑
if ($owned) {
$this->touchBatch($batchId);
$this->svc->log(
'ReferenceCheckArticleWorker abort batch_id=' . $batchId
. ' p_article_id=' . $pArticleId
. ' err=' . $e->getMessage()
);
}
throw $e;
} finally {
if ($owned && !$finished) {
// 消息进 DLQ 后主队列可能没人再推本批:主动再投递,便于稍后续跑
try {
(new ReferenceCheckMqPublisher())->publishArticleStart($pArticleId, $batchId, $trigger);
} catch (\Exception $pubErr) {
$this->svc->log('ReferenceCheckArticleWorker republish after abort failed: ' . $pubErr->getMessage());
}
}
}
$this->svc->recoverQueueRowsForArticle($pArticleId);
if ($trigger !== 'recheck_pending_only'
&& ReferenceRelevanceCheckService::PREPARE_LITERATURE_BEFORE_CHECK) {
$this->svc->prepareLiteratureContentByArticle($pArticleId);
}
$this->svc->log('ReferenceCheckArticleWorker start p_article_id=' . $pArticleId . ' batch_id=' . $batchId);
// 快照本批待处理 id联合引用组长一次会整组落库循环计数会小于 total_count收尾按快照回填
$trackedIds = $this->listPendingCheckIds($pArticleId);
$done = 0;
$failed = 0;
while (true) {
$row = $this->fetchNextPendingRow($pArticleId);
if (empty($row)) {
break;
}
$checkId = $this->svc->resolveCheckRowId($row);
if ($checkId <= 0) {
continue;
}
$result = $this->processOneRow($checkId, $row, $trigger === 'recheck_pending_only');
if ($result === 'ok') {
$done++;
} elseif ($result === 'failed') {
$failed++;
}
}
if (!empty($trackedIds)) {
$stats = $this->summarizeTrackedCheckIds($trackedIds);
$done = intval($stats['done']);
$failed = intval($stats['failed']);
}
$this->finalizeBatch($batchId, $done, $failed);
$this->svc->log('ReferenceCheckArticleWorker done p_article_id=' . $pArticleId . ' batch_id=' . $batchId . ' done=' . $done . ' failed=' . $failed);
$this->publishNextWaitingBatch();
}
private function listPendingCheckIds($pArticleId)
/**
* @return string claim|resume|skip
*/
private function claimOrResumeBatch($batchId)
{
$batchId = intval($batchId);
$now = date('Y-m-d H:i:s');
$claimed = Db::name('article_reference_relevance_check_batch')
->where('id', $batchId)
->where('batch_status', self::BATCH_WAITING)
->update([
'batch_status' => self::BATCH_RUNNING,
'updated_at' => $now,
]);
if (intval($claimed) > 0) {
return 'claim';
}
$batch = $this->getBatch($batchId);
if (empty($batch)) {
return 'skip';
}
$status = intval($batch['batch_status']);
if ($status === self::BATCH_DONE || $status === self::BATCH_PARTIAL_FAILED) {
return 'skip';
}
if ($status === self::BATCH_RUNNING) {
if ($this->isBatchStale($batch)) {
// 僵尸 RUNNING抢占续跑不重置已完成明细
Db::name('article_reference_relevance_check_batch')
->where('id', $batchId)
->where('batch_status', self::BATCH_RUNNING)
->update(['updated_at' => $now]);
$this->svc->log('ReferenceCheckArticleWorker reclaim stale batch_id=' . $batchId);
return 'resume';
}
// 仍有心跳,说明别的消费者在跑本批
return 'skip';
}
return 'skip';
}
private function isBatchStale(array $batch)
{
$updatedAt = isset($batch['updated_at']) ? strtotime((string)$batch['updated_at']) : 0;
if ($updatedAt <= 0) {
return true;
}
return (time() - $updatedAt) >= self::BATCH_STALE_SECONDS;
}
private function touchBatch($batchId)
{
Db::name('article_reference_relevance_check_batch')
->where('id', intval($batchId))
->where('batch_status', self::BATCH_RUNNING)
->update(['updated_at' => date('Y-m-d H:i:s')]);
}
/**
* 其它文章僵尸 RUNNING → 改回 WAITING 并重新入队,从卡死条续跑
*/
private function recoverStaleForeignBatches($exceptBatchId)
{
$exceptBatchId = intval($exceptBatchId);
$staleBefore = date('Y-m-d H:i:s', time() - self::BATCH_STALE_SECONDS);
// ThinkPHP 5whereRaw 绑参会与命名占位符冲突(HY093),日期直接拼进 SQL
$staleSql = "(updated_at IS NULL OR updated_at < '" . addslashes($staleBefore) . "')";
$rows = Db::name('article_reference_relevance_check_batch')
->where('batch_status', self::BATCH_RUNNING)
->where('id', '<>', $exceptBatchId)
->whereRaw($staleSql)
->order('id asc')
->limit(20)
->select();
if (empty($rows)) {
return;
}
$publisher = new ReferenceCheckMqPublisher();
foreach ($rows as $row) {
$bid = intval($row['id']);
$pid = intval($row['p_article_id']);
$affected = Db::name('article_reference_relevance_check_batch')
->where('id', $bid)
->where('batch_status', self::BATCH_RUNNING)
->whereRaw($staleSql)
->update([
'batch_status' => self::BATCH_WAITING,
'updated_at' => date('Y-m-d H:i:s'),
]);
if (intval($affected) <= 0) {
continue;
}
// 强制收回卡死行;已完成条保持不动
$this->svc->recoverQueueRowsForArticle($pid, true);
$this->svc->log('ReferenceCheckArticleWorker recover stale foreign batch_id=' . $bid . ' p_article_id=' . $pid);
try {
$publisher->publishArticleStart(
$pid,
$bid,
isset($row['trigger']) ? $row['trigger'] : 'enqueue'
);
} catch (\Exception $e) {
$this->svc->log('ReferenceCheckArticleWorker recover publish failed batch_id=' . $bid . ' err=' . $e->getMessage());
}
}
}
private function summarizeArticleCheckStats($pArticleId)
{
$rows = Db::name('article_reference_relevance_check_result')
->where('p_article_id', intval($pArticleId))
->where('queue_status', ReferenceRelevanceCheckService::QUEUE_PENDING)
->where('status', ReferenceRelevanceCheckService::RECORD_PENDING)
->field('id')
->select();
$ids = [];
foreach ($rows as $row) {
$id = intval(isset($row['id']) ? $row['id'] : 0);
if ($id > 0) {
$ids[] = $id;
}
}
return $ids;
}
private function summarizeTrackedCheckIds(array $checkIds)
{
$checkIds = array_values(array_filter(array_map('intval', $checkIds)));
if (empty($checkIds)) {
return ['done' => 0, 'failed' => 0];
}
$rows = Db::name('article_reference_relevance_check_result')
->whereIn('id', $checkIds)
->field('id,status')
->field('status')
->select();
$done = 0;
$failed = 0;
$pending = 0;
foreach ($rows as $row) {
$st = intval(isset($row['status']) ? $row['status'] : -1);
if ($st === ReferenceRelevanceCheckService::RECORD_COMPLETED) {
$done++;
} elseif ($st === ReferenceRelevanceCheckService::RECORD_FAILED) {
$failed++;
} elseif ($st === ReferenceRelevanceCheckService::RECORD_PENDING) {
$pending++;
}
}
return ['done' => $done, 'failed' => $failed];
return [
'done' => $done,
'failed' => $failed,
'pending' => $pending,
'total' => $done + $failed + $pending,
];
}
private function canStartArticleWork($batchId)
@@ -147,20 +325,6 @@ class ReferenceCheckArticleWorker
return intval($running) === 0;
}
private function claimBatch($batchId)
{
$now = date('Y-m-d H:i:s');
$affected = Db::name('article_reference_relevance_check_batch')
->where('id', intval($batchId))
// 只允许 WAITING -> RUNNING禁止已 RUNNING 的批次被重复 claim
->where('batch_status', self::BATCH_WAITING)
->update([
'batch_status' => self::BATCH_RUNNING,
'updated_at' => $now,
]);
return intval($affected) > 0;
}
private function getBatch($batchId)
{
return Db::name('article_reference_relevance_check_batch')->where('id', intval($batchId))->find();
@@ -168,12 +332,22 @@ class ReferenceCheckArticleWorker
private function fetchNextPendingRow($pArticleId)
{
return Db::name('article_reference_relevance_check_result')
$rows = Db::name('article_reference_relevance_check_result')
->where('p_article_id', intval($pArticleId))
->where('queue_status', ReferenceRelevanceCheckService::QUEUE_PENDING)
->where('status', ReferenceRelevanceCheckService::RECORD_PENDING)
->order('reference_no asc,am_id asc,text_start asc,id asc')
->find();
->limit(100)
->select();
if (empty($rows)) {
return null;
}
foreach ($rows as $row) {
if ($this->svc->shouldProcessRelevanceRowNow($row)) {
return $row;
}
}
return null;
}
/**
@@ -185,7 +359,10 @@ class ReferenceCheckArticleWorker
$claimed = Db::name('article_reference_relevance_check_result')
->where('id', intval($checkId))
->where('queue_status', ReferenceRelevanceCheckService::QUEUE_PENDING)
->update(['queue_status' => ReferenceRelevanceCheckService::QUEUE_RUNNING]);
->update([
'queue_status' => ReferenceRelevanceCheckService::QUEUE_RUNNING,
'updated_at' => date('Y-m-d H:i:s'),
]);
if (intval($claimed) <= 0) {
return 'skip';
}
@@ -193,13 +370,43 @@ class ReferenceCheckArticleWorker
$retryCount = intval(isset($row['retry_count']) ? $row['retry_count'] : 0);
try {
$this->svc->runCheckOnce($checkId, $skipLiteratureFetch);
$this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_COMPLETED, $retryCount);
DbReconnectHelper::ensure();
$fresh = Db::name('article_reference_relevance_check_result')->where('id', intval($checkId))->find();
if (empty($fresh)) {
return 'skip';
}
$st = intval($fresh['status']);
if ($st === ReferenceRelevanceCheckService::RECORD_COMPLETED) {
$this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_COMPLETED, $retryCount);
return 'ok';
}
if ($st === ReferenceRelevanceCheckService::RECORD_FAILED) {
$this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_FAILED, $retryCount);
return 'failed';
}
// 分块部分成功:本行仍 pending留给后续消息补跑
$this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_PENDING, $retryCount);
return 'ok';
} catch (\Exception $e) {
$this->svc->log('ReferenceCheckArticleWorker check_id=' . $checkId . ' err=' . $e->getMessage());
DbReconnectHelper::ensure();
// 联合组组员被提前领取:不算失败,交还 pending 等组长
if (intval($e->getCode()) === 9001
|| strpos($e->getMessage(), 'Citation group leader not finished') !== false) {
$this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_PENDING, $retryCount);
return 'skip';
}
if ($this->svc->isRelevanceLlmFailureMessage($e->getMessage())) {
$this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_PENDING, $retryCount);
return 'ok';
}
try {
$fresh = Db::name('article_reference_relevance_check_result')->where('id', intval($checkId))->find();
if (!empty($fresh) && intval($fresh['status']) === ReferenceRelevanceCheckService::RECORD_COMPLETED) {
// 异常前已有分块落库成功,保留成果,本行按完成处理
$this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_COMPLETED, $retryCount);
return 'ok';
}
if (!empty($fresh) && intval($fresh['status']) === ReferenceRelevanceCheckService::RECORD_FAILED) {
if (intval($fresh['queue_status']) !== ReferenceRelevanceCheckService::QUEUE_FAILED) {
$this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_FAILED, $retryCount);
@@ -208,7 +415,16 @@ class ReferenceCheckArticleWorker
}
$groupRows = !empty($fresh) ? $this->svc->findCitationGroupRowsForWorker($fresh) : [];
if (!empty($groupRows)) {
$this->svc->failGroupWithQueue($groupRows, $e->getMessage(), $retryCount);
// 只失败仍未完成的行,已 completed 的分块结果保留
$incomplete = [];
foreach ($groupRows as $gr) {
if (intval($gr['status']) !== ReferenceRelevanceCheckService::RECORD_COMPLETED) {
$incomplete[] = $gr;
}
}
if (!empty($incomplete)) {
$this->svc->failGroupWithQueue($incomplete, $e->getMessage(), $retryCount);
}
} else {
$this->svc->updateCheckResult($checkId, [
'status' => ReferenceRelevanceCheckService::RECORD_FAILED,
@@ -223,16 +439,18 @@ class ReferenceCheckArticleWorker
}
}
private function finalizeBatch($batchId, $done, $failed)
private function finalizeBatch($batchId, $done, $failed, $total = 0)
{
$batch = $this->getBatch($batchId);
if (empty($batch)) {
return;
}
$total = intval($batch['total_count']);
$done = intval($done);
$failed = intval($failed);
// 快照回填后若实际终态条数多于入队 total抬升 total 保持一致
$total = intval($total);
if ($total <= 0) {
$total = intval($batch['total_count']);
}
if (($done + $failed) > $total) {
$total = $done + $failed;
}

File diff suppressed because it is too large Load Diff