Compare commits
16 Commits
a3a0ceef17
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f87057488b | ||
|
|
a1e151048c | ||
|
|
175d2edc55 | ||
|
|
32b3233378 | ||
| 16961465ac | |||
| bffac7755d | |||
|
|
5d40b8358c | ||
|
|
d72b16d9e8 | ||
| f4ebd10bfa | |||
| 1e7afcbeff | |||
|
|
2db6f170fe | ||
|
|
0aa4a82b48 | ||
| 32fb903c5d | |||
| 485d7c8d97 | |||
| 97fa5ed432 | |||
| 33f1bdc729 |
14
.env
14
.env
@@ -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
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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数据,不要额外文字,包含字段:doilink(url格式)、title(标题)、author(作者数组)、joura(出版社名称)、dateno(年;卷(期):起始页-终止页),is_ai_check(默认1)
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, '.') . '.';
|
||||
}
|
||||
|
||||
|
||||
|
||||
201
application/common/AuthorListFormatter.php
Normal file
201
application/common/AuthorListFormatter.php
Normal 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, " ,;");
|
||||
}
|
||||
}
|
||||
554
application/common/BookCitationParser.php
Normal file
554
application/common/BookCitationParser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
705
application/common/BookMetadataService.php
Normal file
705
application/common/BookMetadataService.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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):Pages(如 2024;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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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 的方式处理数据
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 表示 210–218),展开逻辑由调用方处理。
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 图书绝大多数没有 DOI,Crossref 和原文都给不出 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)
|
||||
|
||||
@@ -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) 中华医学会期刊 DOI:OpenAlex 中文摘要(可选 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,
|
||||
];
|
||||
|
||||
263
application/common/ReferenceMetadataService.php
Normal file
263
application/common/ReferenceMetadataService.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
use think\Env;
|
||||
|
||||
/**
|
||||
* 参考文献元数据聚合:PubMed 优先,Crossref 补全。
|
||||
*
|
||||
* 医学期刊的著录信息以 PubMed(NLM 人工校订)更贴合期次著录:
|
||||
* - 年份: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/");
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
// 默认仅回收“长时间未更新”的 RUNNING;force 时立刻回退,从卡死条继续
|
||||
$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
|
||||
*/
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 5:whereRaw 绑参会与命名占位符冲突(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
Reference in New Issue
Block a user