From 33f1bdc72938773d17b79129b007260db2deb4a3 Mon Sep 17 00:00:00 2001 From: wyn <1074145239@qq.com> Date: Tue, 4 Aug 2026 10:36:47 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E5=AE=98=E7=BD=91=E4=B8=93=E5=88=8A?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=96=87=E4=BB=B6=E6=B7=BB=E5=8A=A020M?= =?UTF-8?q?=E7=9A=84=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/api/controller/Special.php | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/application/api/controller/Special.php b/application/api/controller/Special.php index 6685d75c..a6069a6e 100644 --- a/application/api/controller/Special.php +++ b/application/api/controller/Special.php @@ -923,11 +923,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 +1056,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 +2088,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); From 97fa5ed43244b7c4e299463ad50b28ebd016c805 Mon Sep 17 00:00:00 2001 From: wyn <1074145239@qq.com> Date: Tue, 4 Aug 2026 10:38:51 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=B8=93=E5=88=8A=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=BC=98=E5=8C=96=EF=BC=8C=E5=AE=98=E7=BD=91?= =?UTF-8?q?=E5=92=8C=E6=8A=95=E7=A8=BF=E7=B3=BB=E7=BB=9F=E6=9C=9F=E5=88=8A?= =?UTF-8?q?id=E4=B8=8D=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.env b/.env index 5145219a..260da1a2 100644 --- a/.env +++ b/.env @@ -66,8 +66,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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 From 32fb903c5d45c7b6b501f1cf308a3d3ff582fbc2 Mon Sep 17 00:00:00 2001 From: wyn <1074145239@qq.com> Date: Tue, 4 Aug 2026 17:11:53 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=B8=80=E5=A4=84=E5=8E=9F=E6=96=87?= =?UTF-8?q?=E5=BC=95=E7=94=A820=E5=A4=9A=E4=B8=AA=E5=8F=82=E8=80=83?= =?UTF-8?q?=E6=96=87=E7=8C=AE=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/api/controller/Special.php | 71 ++++- .../common/ReferenceRelevanceCheckService.php | 25 +- .../common/mq/ReferenceCheckArticleWorker.php | 287 ++++++++++++------ .../service/ReferenceRelevanceLlmService.php | 282 ++++++++++++++++- 4 files changed, 559 insertions(+), 106 deletions(-) diff --git a/application/api/controller/Special.php b/application/api/controller/Special.php index a6069a6e..9e41a305 100644 --- a/application/api/controller/Special.php +++ b/application/api/controller/Special.php @@ -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.

'; + $tt1 .= 'The following information was acknowledged during the submission process: '.''.$journal_info['title'].''.' is an open access journal that charges a publication fee of '.$iJournalFee.' USD for accepted manuscripts (click here for details).

'; + $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 '.''.$journal_info['title'].''.', you will be informed as soon as possible.

'; + $tt1 .= 'If you encounter any problems, please contact '.$journal_info['email'].'.

Thank you for choosing to submit your manuscript to '.''.$journal_info['title'].''.'.


'; + $tt1 .= 'Sincerely,
Editorial Office
'; + $tt1 .= ''.$journal_info['title'].'' . '
'; + $tt1 .= 'Email: ' . $journal_info['email'] . '
'; + $tt1 .= 'Website: ' . $journal_info['website'] . '
'; + $tt1 .= '
If you have any questions, please contact us:
'; + $tt1 .= 'Head of publication ethics
Dr. Dan Chen
TMR Publishing Group Limited Company, Auckland, New Zealand
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 ' . ''.$journal_info['title'].'' . ', you will be informed as soon as possible.


'; + $tt1 .= 'You may check on the status of this manuscript in the Submission System. If you encounter any problems, please contact ' . $journal_info['email'] . '.

'; + $tt1 .= 'Thank you for choosing to submit your manuscript to ' . ''.$journal_info['title'].'' . '.


'; + $tt1 .= 'Sincerely,
Editorial Office
'; + $tt1 .= 'Subscribe to this journal
'; + $tt1 .= ''.$journal_info['title'].'' . '
'; + $tt1 .= 'Email: ' . $journal_info['email'] . '
'; + $tt1 .= 'Website: ' . $journal_info['website'] . '
'; + $tt1 .= '
If you have any questions, please contact us:
'; + $tt1 .= 'Head of publication ethics
Dr. Dan Chen
TMR Publishing Group Limited Company, Auckland, New Zealand
Email: publisher@tmrjournals.com'; + } + foreach ($data['authorList'] as $v) { + $cache_str = 'Dear Dr. ' . $v['firstname'] ." ".$v['lastname'] . ',

'; + $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; @@ -2242,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()]); + } + } + } } diff --git a/application/common/ReferenceRelevanceCheckService.php b/application/common/ReferenceRelevanceCheckService.php index 137d0d79..37c08b7a 100644 --- a/application/common/ReferenceRelevanceCheckService.php +++ b/application/common/ReferenceRelevanceCheckService.php @@ -837,7 +837,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 +856,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) diff --git a/application/common/mq/ReferenceCheckArticleWorker.php b/application/common/mq/ReferenceCheckArticleWorker.php index 61f54085..5e82d4a8 100644 --- a/application/common/mq/ReferenceCheckArticleWorker.php +++ b/application/common/mq/ReferenceCheckArticleWorker.php @@ -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,9 @@ class ReferenceCheckArticleWorker return; } + // 先释放其它文章上的僵尸 RUNNING,避免全局串行永久堵死 + $this->recoverStaleForeignBatches($batchId); + if (!$this->canStartArticleWork($batchId)) { $this->svc->log('ReferenceCheckArticleWorker defer batch_id=' . $batchId . ' other article running'); (new ReferenceCheckMqPublisher())->publishArticleStart( @@ -47,95 +54,206 @@ 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; + 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) { + $row = $this->fetchNextPendingRow($pArticleId); + if (empty($row)) { + break; + } + $checkId = $this->svc->resolveCheckRowId($row); + if ($checkId <= 0) { + continue; + } + $this->processOneRow($checkId, $row, $trigger === 'recheck_pending_only'); + // 每条结束后刷新批次心跳,长文不会被误判为僵尸 + $this->touchBatch($batchId); + } + + $stats = $this->summarizeArticleCheckStats($pArticleId); + $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); + $rows = Db::name('article_reference_relevance_check_batch') + ->where('batch_status', self::BATCH_RUNNING) + ->where('id', '<>', $exceptBatchId) + ->whereRaw('(updated_at IS NULL OR updated_at < ?)', [$staleBefore]) + ->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('(updated_at IS NULL OR updated_at < ?)', [$staleBefore]) + ->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 +265,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(); @@ -185,7 +289,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'; } @@ -223,16 +330,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; } diff --git a/application/common/service/ReferenceRelevanceLlmService.php b/application/common/service/ReferenceRelevanceLlmService.php index 9d3879d2..2e6f3beb 100644 --- a/application/common/service/ReferenceRelevanceLlmService.php +++ b/application/common/service/ReferenceRelevanceLlmService.php @@ -19,6 +19,8 @@ class ReferenceRelevanceLlmService private $maxReferChars; private $maxAbstractChars; private $maxTokens; + /** 本次校对的联合引用组总篇数(分块时仍为整组篇数,供单篇分摊评分使用) */ + private $groupRefCount = 0; public function __construct() { @@ -72,6 +74,7 @@ class ReferenceRelevanceLlmService } $refCount = $this->countCiteGroupRefs($citeGroupRefs); + $this->groupRefCount = $refCount; // 默认每批最多 4 篇,降低单次排队/超时风险(可用 env 覆盖) $maxRefsPerCall = max(2, intval(Env::get('promotion.relevance_llm_max_refs_per_call', 4))); if ($refCount > $maxRefsPerCall) { @@ -111,7 +114,8 @@ class ReferenceRelevanceLlmService $citeGroupRefs, array $referTypeMap, $refCount, - array $fallback + array $fallback, + array $fixedClaims = [] ) { $payload = [ 'model' => $this->model, @@ -119,7 +123,7 @@ class ReferenceRelevanceLlmService 'max_tokens' => $this->resolveMaxTokens($refCount), 'messages' => [ ['role' => 'system', 'content' => $this->buildSystemPrompt()], - ['role' => 'user', 'content' => $this->buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $refCount, $referTypeMap)], + ['role' => 'user', 'content' => $this->buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $refCount, $referTypeMap, $fixedClaims)], ], ]; @@ -258,7 +262,8 @@ class ReferenceRelevanceLlmService - 不得因摘要提到 colorectal cancer 就给 0.92 - 仅当文献为流行病学综述/公共卫生研究,或明确讨论发病率、死亡率、疾病负担时,单条才可 **0.85~0.92** 4. **联合分写在顶层 combined_relevance_score / combined_reason**,与单条分必须可分离(例如 [1,2] 时文献1=0.45、文献2=0.92、联合=0.92);**禁止在 results 各条中重复 combined_***。 - **联合分不得与单条分矛盾**:若全部单条均为 0.25,联合必须为 0.25;若全部单条均 ≤0.45,联合不得 >0.45;联合分不得远高于最高单条分(禁止「单条全弱、联合高分」)。 + **联合分不得与单条分矛盾**:若全部单条均为 0.25,联合必须为 0.25;若全部单条均 ≤0.45,联合不得 >0.45(此处「单条弱」指主语不一致/证据层级不足等**实质弱相关**,不含规则18的分工覆盖情形)。 + **但联合分不是「取单篇最高分」**:各篇分工覆盖不同 Claim 时,联合按规则18的整组覆盖并集定档,可高于任一单篇。 5. **「来源/化学分类」型句子**(naturally occurring、pentacyclic triterpenoid、found in fruits/vegetables/medicinal plants、并列举具体植物学名): - 先判文献类型:来源综述 / 生物活性综述 最适合;**抗癌治疗综述**对「来源分布」claim 通常仅 **0.65** - 单篇可差异化打分(如 0.92 / 0.92 / 0.65),**不得**因联合而三篇都给高分 @@ -301,7 +306,14 @@ class ReferenceRelevanceLlmService - 仅间接沾边、层级或主语明显不符 → **≤0.65** - **联合分**:多篇经典教材已系统覆盖全部并列功能时,联合可 **0.98**,不得因个别子类/哲学文献偏窄而压低整体 14. **分值必须与你写出的 Claim 覆盖自洽(写完 reason 后必须自检;通用规则,适用所有文献类型)**: - - 先按你写出的覆盖标注计算「覆盖比例」= (完整✔数 + 0.5×部分数) / Claim总数,再据此定分: + - **先判本组是单独引用还是联合引用**:cite_group_refs 含多篇时按下方「联合引用分摊原则」评分;仅 1 篇时才按整段 Claim 总数算覆盖比例。 + - **联合引用分摊原则(多篇联合时优先适用)**:整段 Claim 由组内各篇**分工覆盖**,**不要求任何单篇覆盖全部 Claim**。 + - 单篇评分只看「**它覆盖的那几项 Claim 支撑得是否充分**」,**不看**「占整段 Claim 的比例」 + - **主语一致 + 完整✔至少 1 项 Claim** → 单篇 **不低于 0.85**(已充分承担分工),**严禁**因未覆盖他篇负责的 Claim 而降到 0.45/0.25 + - 主语一致但只有「部分」、无任何完整✔ → **0.65** + - 单篇**上限 0.85**:只承担整段一两项分工者不给 0.92/0.98(0.92+ 留给单篇即覆盖整段绝大多数 Claim 的情形) + - 组内多篇覆盖同一项 Claim 属**重复引用**,各篇分值照常给,但须在 combined_reason 点名 + - 单独引用(组内仅 1 篇)时,按你写出的覆盖标注计算「覆盖比例」= (完整✔数 + 0.5×部分数) / Claim总数,再据此定分: - 覆盖比例 ≥0.9 且无✘ → **0.92~0.98**(系统/全部覆盖=0.98) - ≥0.72 → **0.92**;≥0.58 → **0.85**;≥0.45 → **0.78**;≥0.28 → **0.65**;≥0.15 → **0.45**;几乎全✘/无覆盖 → 0.25/0.15 - **0.25/0.15 仅用于「主语不一致」或「几乎完全无覆盖(无任何✔/部分)」**。只要**主语一致**且 reason 写出**任意 ✔ 或部分**,就**禁止**给 0.25,至少 **0.45**。 @@ -327,6 +339,14 @@ class ReferenceRelevanceLlmService - **典型示例(多事实背景句 + 单篇原始研究)**:引用处「HF诊疗取得进展 + 老年HF患者QOL较低 + 低于其他慢病老人 + 因长疗程/并发症/急性加重」,文献为 Lee & Song 2015 SEM 症状管理与QOL研究: - Claim 应拆为:A诊疗进展✘、B HF患者QOL相关✔或部分、C 低于其他慢病比较✘、D 具体成因列举部分或✘ - 覆盖约 35%~45% → **0.65(partially_related)**;**不得**因「都研究HF+QOL」给 0.92,**不得**因有多项✘就给 0.25 +18. **联合分按「整组 Claim 覆盖并集」定档(联合引用必用)**: + - 把组内各篇的 ✔/部分**取并集**:某项 Claim 只要有任一篇 ✔ 即记完整,只有「部分」记 0.5,无任何篇覆盖记 ✘ + - 整组覆盖率 = (并集完整数 + 0.5×并集部分数) / Claim 总数,据此定档: + 全部 Claim 均被覆盖 → **0.92~0.98**;≥0.72 → **0.85**;≥0.58 → **0.78**;≥0.28 → **0.65**;更低按实际 + - **联合分与单篇分母不同**:单篇按分工评(规则14),联合按整段覆盖评;单篇偏低不构成压低联合分的理由 + - **combined_reason 必须点名两件事**(这是本次校对对作者最有价值的输出): + ①**无人覆盖的 Claim** → 写「X 暂无文献支撑」,提示作者该处需替换/改引 + ②**被多篇重复覆盖的 Claim** → 写「X 由文献 a、b、c 重复覆盖」,提示引用堆砌可精简 ================================================== 【一、必须先拆解 claim】 @@ -489,7 +509,7 @@ Claim 具体内容见顶层 `claims`,combined_reason **只用字母指代**( PROMPT; } - private function buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $refCount = 0, array $referTypeMap = []) + private function buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $refCount = 0, array $referTypeMap = [], array $fixedClaims = []) { $parts = ["【正文节 t_article_main】\n" . $sectionText]; if (trim((string)$citeGroupRefs) !== '') { @@ -499,6 +519,14 @@ PROMPT; if ($localContext !== '') { $parts[] = "【本引用位置附近上下文(优先据此拆解 claim)】\n" . $localContext; } + if (!empty($fixedClaims)) { + $claimLines = []; + foreach ($fixedClaims as $letter => $text) { + $claimLines[] = $letter . ':' . $text; + } + $parts[] = "【已确定的 Claim 列表(本引用位置已拆解完成,必须沿用相同字母与含义,不得重新编号、改写或增删)】\n" + . implode("\n", $claimLines); + } $typeBlock = $this->formatReferTypeBlock($referTypeMap); if ($typeBlock !== '') { $parts[] = $typeBlock; @@ -534,6 +562,9 @@ PROMPT; } else { $tail .= ' 单条引用:reason ≤220 字,点名各 Claim 覆盖与分值依据;顶层 combined_* 与单条一致。'; } + if ($refCount > 1) { + $tail .= ' 联合引用分摊原则(务必遵守):整段 Claim 由各篇分工覆盖,单篇只要主语一致且完整✔至少 1 项 Claim 即不低于 0.85(上限 0.85),严禁因未覆盖他篇负责的 Claim 而降到 0.45/0.25;仅「部分」覆盖则 0.65。顶层 combined_relevance_score 按各篇覆盖并集算整组覆盖率定档(全覆盖 0.92~0.98、≥0.72 给 0.85、≥0.58 给 0.78、≥0.28 给 0.65),combined_reason 必须点名「哪些 Claim 无任何文献支撑」与「哪些 Claim 被多篇重复覆盖(写明文献编号)」。'; + } $parts[] = $tail; return implode("\n\n", $parts); @@ -732,7 +763,8 @@ PROMPT; $chunkRefs, $chunkTypeMap, count($chunk), - $fallback + $fallback, + $claims ); if (!empty($part['request_failed']) || empty($part['results'])) { $reason = isset($part['reason']) ? (string)$part['reason'] : 'LLM split batch failed'; @@ -771,6 +803,12 @@ PROMPT; if (count($results) > 1) { $bands = $this->getScoreBands(); $adjustedCombined = $this->enforceCombinedAgainstSingles($results, $combinedScore, $bands); + // 各批只看到局部,联合分与说明按全组覆盖并集重算 + $coverage = $this->summarizeGroupCoverage($results); + if ($this->maxSingleRelevanceScore($results) >= 0.65 - 0.001 + && $coverage['floor'] > $adjustedCombined) { + $adjustedCombined = $coverage['floor']; + } if ($combinedScore <= 0 || abs($adjustedCombined - $combinedScore) > 0.001) { $combinedScore = $adjustedCombined; $combinedReason = $this->fallbackReasonFromScore( @@ -778,6 +816,7 @@ PROMPT; $this->levelFromScore($combinedScore) ); } + $combinedReason = $this->appendGroupCoverageNote($combinedReason, $coverage); } elseif (count($results) === 1) { $combinedScore = floatval($results[0]['relevance_score'] ?? 0); $combinedReason = (string)($results[0]['reason'] ?? ''); @@ -939,6 +978,18 @@ PROMPT; $refLit = $this->extractRefLiteratureFromCombined($abstractText, $refNo); $adjusted = $this->reconcileScoreAgainstCoverage($score, $reason, $refLit); $capped = $this->reconcileScoreCeiling($adjusted, $reason); + // 联合引用分摊保底:整段 Claim 由多篇分工覆盖时,按整段分母算出的低分与✘数封顶均不适用 + $shareFloor = $this->resolveJointShareFloor($reason); + if ($shareFloor > $capped) { + \think\Log::warning(sprintf( + 'ReferenceRelevanceLlm: ref#%d joint share floor %.2f->%.2f (group=%d)', + $refNo, + $capped, + $shareFloor, + $this->groupRefCount + )); + $capped = $shareFloor; + } if ($capped > $score + 0.001 || $capped < $score - 0.001) { if ($capped > $score + 0.001) { \think\Log::warning(sprintf( @@ -1070,8 +1121,16 @@ PROMPT; } } + // 联合分按整组覆盖并集定档:各篇分工覆盖时不受单篇分母稀释拖累 + $coverage = $this->summarizeGroupCoverage($outRows); + if ($this->maxSingleRelevanceScore($outRows) >= 0.65 - 0.001 + && $coverage['floor'] > $combinedScore) { + $combinedScore = $coverage['floor']; + } + list($combinedScore,) = $this->enforceCombinedConsistency($combinedScore, '', $bands); $combinedReason = $this->reconcileReasonScore($this->cleanReason($combinedReason), $combinedScore); + $combinedReason = $this->appendGroupCoverageNote($combinedReason, $coverage); return [ 'combined_relevance_score' => $combinedScore, @@ -1372,6 +1431,217 @@ PROMPT; ]; } + /** + * 解析 reason 中的 Claim 覆盖标注,返回 字母 => full|partial|fail。 + * 同一字母多次出现时取最强标注(完整 > 部分 > 未覆盖)。 + */ + private function extractCoverageMarks($reason) + { + $reason = (string)$reason; + $covText = $reason; + if (preg_match('/Claim\s*覆盖[::]\s*(.+?)(?:。|$)/us', $reason, $m)) { + $covText = $m[1]; + } + + $marks = []; + if (preg_match_all('/([A-Z])(?:\s*(✔|✘|×|部分))/u', $covText, $tokens, PREG_SET_ORDER)) { + foreach ($tokens as $tok) { + $letter = $tok[1]; + $mark = $tok[2] === '✔' ? 'full' : ($tok[2] === '部分' ? 'partial' : 'fail'); + $cur = isset($marks[$letter]) ? $marks[$letter] : ''; + if ($cur === 'full' || ($cur === 'partial' && $mark === 'fail')) { + continue; + } + $marks[$letter] = $mark; + } + } + + return $marks; + } + + /** + * 联合引用中,整段 Claim 由各篇分工覆盖,单篇不应因未覆盖他篇负责的 Claim 而被判弱相关。 + * 按「分摊份额」(本篇覆盖数 ÷ 人均应覆盖数)给一个保底分。 + * + * @return float 保底分;0 表示不适用(单独引用、主语不一致、无覆盖标注等) + */ + private function resolveJointShareFloor($reason) + { + $groupSize = intval($this->groupRefCount); + if ($groupSize < 2) { + return 0.0; + } + $reason = (string)$reason; + if ($reason === '' || !preg_match('/主语一致/u', $reason)) { + return 0.0; + } + // 主语/层级/类型不适配属实质弱相关,分摊原则不适用 + if (preg_match('/主语不一致|主语层级|层级不对|层级不一致|类型不符合|类型不适配|不适合支撑/ui', $reason)) { + return 0.0; + } + + $cov = $this->countCoverageSignals($reason); + $full = $cov['full']; + $partial = $cov['partial']; + $total = $cov['total']; + if ($total < 1 || ($full === 0 && $partial === 0)) { + return 0.0; + } + + $expected = $total / $groupSize; + if ($expected <= 0) { + return 0.0; + } + $shareRatio = min(1.0, ($full + 0.5 * $partial) / $expected); + + if ($shareRatio >= 0.72) { + $floor = 0.92; + } elseif ($shareRatio >= 0.58) { + $floor = 0.85; + } elseif ($shareRatio >= 0.45) { + $floor = 0.78; + } elseif ($shareRatio >= 0.28) { + $floor = 0.65; + } else { + return 0.0; + } + + // 完成分摊份额不等于覆盖全段:完整覆盖至少一项封顶 0.85,仅部分覆盖封顶 0.65 + $ceiling = $full >= 1 ? 0.85 : 0.65; + + return $this->snapScore(min($floor, $ceiling), $this->getScoreBands()); + } + + /** + * 汇总整组 Claim 覆盖情况:各篇覆盖取并集,得出整组覆盖率、无人覆盖项与重复覆盖项。 + * + * @return array{total:int,full:int,partial:int,ratio:float,floor:float,missing:array,duplicated:array} + */ + private function summarizeGroupCoverage(array $results) + { + $empty = [ + 'total' => 0, + 'full' => 0, + 'partial' => 0, + 'ratio' => 0.0, + 'floor' => 0.0, + 'missing' => [], + 'duplicated' => [], + ]; + if (count($results) < 2) { + return $empty; + } + + $union = []; + $coveredBy = []; + foreach ($results as $row) { + $reason = isset($row['reason']) ? (string)$row['reason'] : ''; + if ($reason === '') { + continue; + } + $refNo = intval(isset($row['reference_no']) ? $row['reference_no'] : 0); + foreach ($this->extractCoverageMarks($reason) as $letter => $mark) { + $cur = isset($union[$letter]) ? $union[$letter] : 'fail'; + if ($mark === 'full' || $cur === 'full') { + $union[$letter] = 'full'; + } elseif ($mark === 'partial' || $cur === 'partial') { + $union[$letter] = 'partial'; + } else { + $union[$letter] = 'fail'; + } + if ($mark === 'full' && $refNo > 0) { + $coveredBy[$letter][] = $refNo; + } + } + } + + $total = count($union); + if ($total < 1) { + return $empty; + } + ksort($union, SORT_STRING); + + $full = $partial = 0; + $missing = []; + foreach ($union as $letter => $mark) { + if ($mark === 'full') { + $full++; + } elseif ($mark === 'partial') { + $partial++; + } else { + $missing[] = $letter; + } + } + if ($full === 0 && $partial === 0) { + return $empty; + } + + $ratio = ($full + 0.5 * $partial) / $total; + if ($ratio >= 0.9 && $full === $total) { + $floor = 0.92; + } elseif ($ratio >= 0.72) { + $floor = 0.85; + } elseif ($ratio >= 0.58) { + $floor = 0.78; + } elseif ($ratio >= 0.28) { + $floor = 0.65; + } else { + $floor = 0.0; + } + + $duplicated = []; + foreach ($coveredBy as $letter => $refNos) { + $refNos = array_values(array_unique($refNos)); + if (count($refNos) > 1) { + sort($refNos, SORT_NUMERIC); + $duplicated[$letter] = $refNos; + } + } + ksort($duplicated, SORT_STRING); + + return [ + 'total' => $total, + 'full' => $full, + 'partial' => $partial, + 'ratio' => $ratio, + 'floor' => $floor > 0 ? $this->snapScore($floor, $this->getScoreBands()) : 0.0, + 'missing' => $missing, + 'duplicated' => $duplicated, + ]; + } + + /** + * 在 combined_reason 末尾追加整组覆盖说明(缺口 Claim 与重复覆盖 Claim)。 + * 分块校对时各批会各写一次,故先清除旧标记再按全组重写。 + */ + private function appendGroupCoverageNote($combinedReason, array $summary) + { + $combinedReason = trim((string)$combinedReason); + $combinedReason = trim(preg_replace('/【整组覆盖】.*$/us', '', $combinedReason)); + if (intval($summary['total']) < 1) { + return $combinedReason; + } + + $segments = [sprintf('Claim 覆盖 %d/%d', $summary['full'], $summary['total'])]; + if (!empty($summary['missing'])) { + $segments[] = implode('、', $summary['missing']) . ' 暂无文献支撑,建议替换相应编号文献或调整该句表述'; + } + if (!empty($summary['duplicated'])) { + $dup = []; + foreach ($summary['duplicated'] as $letter => $refNos) { + $dup[] = $letter . ' 由文献 ' . implode('、', $refNos) . ' 重复覆盖'; + } + $segments[] = implode(';', $dup) . ',可酌情精简'; + } + if (count($segments) < 2) { + return $combinedReason; + } + + $note = '【整组覆盖】' . implode(';', $segments) . '。'; + + return $combinedReason === '' ? $note : $combinedReason . ' ' . $note; + } + /** * 从联合文献块中提取指定编号的摘要/清洗内容。 */