From bffac7755d8706ab4a7f604a8addb70fcac6c979 Mon Sep 17 00:00:00 2001
From: wyn <1074145239@qq.com>
Date: Thu, 6 Aug 2026 10:14:36 +0800
Subject: [PATCH] =?UTF-8?q?=E5=8F=82=E8=80=83=E6=96=87=E7=8C=AE=E6=A8=A1?=
=?UTF-8?q?=E5=9E=8B=E6=A0=A1=E5=AF=B9=E6=8D=A2=E9=98=BF=E9=87=8C=E4=BA=91?=
=?UTF-8?q?=E7=99=BE=E7=82=BC=EF=BC=8C=E5=AE=8C=E5=96=84=E5=90=84=E7=A7=8D?=
=?UTF-8?q?=E6=A0=A1=E5=AF=B9=E7=BB=86=E8=8A=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env | 8 +
...roductionArticleReferLiteratureService.php | 6 +
application/common/PubmedService.php | 33 +-
.../ReferenceLiteratureFetchService.php | 19 +
.../common/ReferenceRelevanceCheckService.php | 667 +++++++++++++-
.../common/mq/ReferenceCheckArticleWorker.php | 129 ++-
.../service/ReferenceRelevanceLlmService.php | 824 ++++++++++++++++--
7 files changed, 1551 insertions(+), 135 deletions(-)
diff --git a/.env b/.env
index c17a547a..c9db2a5a 100644
--- a/.env
+++ b/.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
diff --git a/application/common/ProductionArticleReferLiteratureService.php b/application/common/ProductionArticleReferLiteratureService.php
index 89a45ab6..2fcd2a1b 100644
--- a/application/common/ProductionArticleReferLiteratureService.php
+++ b/application/common/ProductionArticleReferLiteratureService.php
@@ -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),
diff --git a/application/common/PubmedService.php b/application/common/PubmedService.php
index 49aec572..93a9c80c 100644
--- a/application/common/PubmedService.php
+++ b/application/common/PubmedService.php
@@ -60,8 +60,8 @@ class PubmedService
$pmid = trim($pmid);
if ($pmid === '') return null;
- // v2:解析结果新增 journal_iso_abbr / journal_medline_ta,换 key 避免命中旧缓存
- $cacheKey = 'pmid_v2_' . $pmid;
+ // v3:解析结果新增 language / journal_country / affiliations,换 key 避免命中旧缓存
+ $cacheKey = 'pmid_v3_' . $pmid;
$cached = $this->cacheGet($cacheKey, 30 * 86400);
if (is_array($cached)) return $cached;
@@ -254,6 +254,31 @@ 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;
}
@@ -267,6 +292,10 @@ 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,
];
}
diff --git a/application/common/ReferenceLiteratureFetchService.php b/application/common/ReferenceLiteratureFetchService.php
index 1cdc1d49..b6d4546d 100644
--- a/application/common/ReferenceLiteratureFetchService.php
+++ b/application/common/ReferenceLiteratureFetchService.php
@@ -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,
];
diff --git a/application/common/ReferenceRelevanceCheckService.php b/application/common/ReferenceRelevanceCheckService.php
index 37c08b7a..b9032e43 100644
--- a/application/common/ReferenceRelevanceCheckService.php
+++ b/application/common/ReferenceRelevanceCheckService.php
@@ -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?"[文献 ".intval($row['reference_no'])."]: ".$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 ? ("[文献 " . intval($row['reference_no']) . "]: " . $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')
@@ -1116,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'] ?? [],
@@ -1491,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']);
@@ -1658,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,
@@ -2077,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'] ?? [],
@@ -2148,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) {
@@ -2156,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';
@@ -2213,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 = [];
@@ -2244,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']),
@@ -2251,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' => '',
@@ -2284,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);
}
@@ -2305,6 +2846,7 @@ class ReferenceRelevanceCheckService
'author_comment' => '',
'combined_relevance_score' => 0,
'combined_reason' => '',
+ 'combined_author_comment' => '',
'claims_json' => '',
'score_ceiling_trigger' => '',
'error_msg' => '',
@@ -2350,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']),
@@ -2365,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')
@@ -2375,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
*/
diff --git a/application/common/mq/ReferenceCheckArticleWorker.php b/application/common/mq/ReferenceCheckArticleWorker.php
index dcd2a974..ebe59f40 100644
--- a/application/common/mq/ReferenceCheckArticleWorker.php
+++ b/application/common/mq/ReferenceCheckArticleWorker.php
@@ -66,6 +66,8 @@ class ReferenceCheckArticleWorker
$owned = true;
$finished = false;
+ $attemptedCheckIds = [];
+ $idleRecovered = false;
try {
// 续跑时强制把卡死行收回 pending,已完成行不动
$this->svc->recoverQueueRowsForArticle($pArticleId, $resume);
@@ -80,29 +82,81 @@ class ReferenceCheckArticleWorker
);
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);
- $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();
+ 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) {
@@ -278,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;
}
/**
@@ -306,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);
@@ -321,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,
diff --git a/application/common/service/ReferenceRelevanceLlmService.php b/application/common/service/ReferenceRelevanceLlmService.php
index 2e6f3beb..146d860f 100644
--- a/application/common/service/ReferenceRelevanceLlmService.php
+++ b/application/common/service/ReferenceRelevanceLlmService.php
@@ -24,12 +24,15 @@ class ReferenceRelevanceLlmService
public function __construct()
{
- $this->url = trim((string)Env::get('promotion.promotion_llm_url', ''));
- $this->model = trim((string)Env::get('promotion.promotion_llm_model', ''));
- $this->apiKey = trim((string)Env::get('promotion.promotion_llm_api_key', ''));
- // 相关性校对固定至少 200s(不跟 promotion_llm_timeout=120);可用 relevance_llm_timeout 单独加大
+ // 相关性校对优先用 RELEVANCE_LLM_*(百炼等);未配置则回退 PROMOTION_LLM_*(本地)
+ $this->url = $this->resolveRelevanceEnv('relevance_llm_url', 'promotion_llm_url');
+ $this->model = $this->resolveRelevanceEnv('relevance_llm_model', 'promotion_llm_model');
+ $this->apiKey = $this->resolveRelevanceEnv('relevance_llm_api_key', 'promotion_llm_api_key');
$timeout = intval(Env::get('promotion.relevance_llm_timeout', 0));
- $this->timeout = max(200, $timeout > 0 ? $timeout : 200);
+ if ($timeout <= 0) {
+ $timeout = 120;
+ }
+ $this->timeout = max(60, $timeout);
// 控制发送给 LLM 的上下文长度,降低单次推理耗时(可通过 env 覆盖)
$this->maxSectionChars = max(1500, intval(Env::get('promotion.relevance_llm_max_section_chars', 4500)));
$this->maxLocalContextChars = max(600, intval(Env::get('promotion.relevance_llm_max_local_context_chars', 1800)));
@@ -39,9 +42,23 @@ class ReferenceRelevanceLlmService
}
/**
- * @return array{results:array,claims?:array,combined_relevance_score?:float,combined_reason?:string,request_failed?:bool,reason?:string}
+ * 读取 promotion.relevance_llm_*,为空则回退 promotion.promotion_llm_*。
*/
- public function checkRelevance($sectionText, $localContext, $referText, $abstractText = '', $citeGroupRefs = '', array $referTypeMap = [])
+ private function resolveRelevanceEnv($relevanceKey, $fallbackKey)
+ {
+ $v = trim((string)Env::get('promotion.' . $relevanceKey, ''));
+ if ($v !== '') {
+ return $v;
+ }
+
+ return trim((string)Env::get('promotion.' . $fallbackKey, ''));
+ }
+
+ /**
+ * @param callable|null $onChunkDone 分块成功回调,用于立即落库
+ * @return array{results:array,claims?:array,combined_relevance_score?:float,combined_reason?:string,request_failed?:bool,reason?:string,partial?:bool}
+ */
+ public function checkRelevance($sectionText, $localContext, $referText, $abstractText = '', $citeGroupRefs = '', array $referTypeMap = [], $onChunkDone = null)
{
$fallback = [
'results' => [],
@@ -60,23 +77,30 @@ class ReferenceRelevanceLlmService
return ['results' => [], 'reason' => 'Empty section or reference text'];
}
+ // 正文/上下文可全局截断;文献书目与摘要必须先按编号分块,再在块内截断,避免后半文献被切没
if (mb_strlen($sectionText) > $this->maxSectionChars) {
$sectionText = mb_substr($sectionText, 0, $this->maxSectionChars);
}
if (mb_strlen($localContext) > $this->maxLocalContextChars) {
$localContext = mb_substr($localContext, 0, $this->maxLocalContextChars);
}
- if (mb_strlen($referText) > $this->maxReferChars) {
- $referText = mb_substr($referText, 0, $this->maxReferChars);
- }
- if (mb_strlen($abstractText) > $this->maxAbstractChars) {
- $abstractText = mb_substr($abstractText, 0, $this->maxAbstractChars);
- }
$refCount = $this->countCiteGroupRefs($citeGroupRefs);
$this->groupRefCount = $refCount;
- // 默认每批最多 4 篇,降低单次排队/超时风险(可用 env 覆盖)
- $maxRefsPerCall = max(2, intval(Env::get('promotion.relevance_llm_max_refs_per_call', 4)));
+
+ // 统计型引用(正文在统计「纳入的 N 项研究」)走整组程序核验,不调 LLM
+ $studySet = $this->tryStudySetShortCircuit($localContext, $sectionText, $citeGroupRefs, $referTypeMap, $refCount);
+ if ($studySet !== null) {
+ return $studySet;
+ }
+
+ // ≥4 篇(大于 3)强制逐篇;≤3 篇可用批量(默认每批最多 2)
+ $perRefThreshold = max(2, intval(Env::get('promotion.relevance_llm_per_ref_threshold', 4)));
+ if ($refCount >= $perRefThreshold) {
+ $maxRefsPerCall = 1;
+ } else {
+ $maxRefsPerCall = max(1, intval(Env::get('promotion.relevance_llm_max_refs_per_call', 2)));
+ }
if ($refCount > $maxRefsPerCall) {
return $this->checkRelevanceByChunks(
$sectionText,
@@ -86,10 +110,14 @@ class ReferenceRelevanceLlmService
$citeGroupRefs,
$referTypeMap,
$refCount,
- $maxRefsPerCall
+ $maxRefsPerCall,
+ $onChunkDone
);
}
+ $referText = $this->truncateText($referText, $this->maxReferChars);
+ $abstractText = $this->truncateText($abstractText, $this->maxAbstractChars);
+
return $this->checkRelevanceOnce(
$sectionText,
$localContext,
@@ -102,6 +130,75 @@ class ReferenceRelevanceLlmService
);
}
+ /**
+ * 命中「纳入研究统计型」引用时,用书目元数据整组核验替代 LLM 逐篇判断。
+ * 这类引用编号是被统计的对象本身,逐篇问「是否支撑该论点」语义不成立,
+ * 且大组会退化成几十次 LLM 调用,故直接短路。
+ *
+ * @return array|null 未命中返回 null,交由常规 LLM 流程处理
+ */
+ private function tryStudySetShortCircuit($localContext, $sectionText, $citeGroupRefs, array $referTypeMap, $refCount)
+ {
+ if (!Env::get('promotion.relevance_study_set_shortcut', true)) {
+ return null;
+ }
+ $minRefs = max(2, intval(Env::get('promotion.relevance_study_set_min_refs', 5)));
+ if ($refCount < $minRefs) {
+ return null;
+ }
+
+ $refNos = $this->parseCiteGroupRefNumbers($citeGroupRefs);
+ if (count($refNos) < $minRefs) {
+ return null;
+ }
+
+ $verifier = new StudySetClaimVerifyService();
+ $claimContext = $verifier->buildContext($localContext, $sectionText);
+ $declared = $verifier->detectDeclared($claimContext);
+ if (empty($declared)) {
+ return null;
+ }
+ // 声明篇数与引用组篇数吻合,或同时命中三个以上统计维度,才认定为枚举式统计引用
+ $totalMatches = intval($declared['total']) > 0 && intval($declared['total']) === count($refNos);
+ if (!$totalMatches && intval($declared['aspect_hits']) < 3) {
+ return null;
+ }
+
+ $verified = $verifier->verify($claimContext, $refNos, $referTypeMap, $declared);
+ if (empty($verified['results'])) {
+ return null;
+ }
+
+ \think\Log::info(sprintf(
+ 'ReferenceRelevanceLlm study-set short-circuit: refs=%d claims=%d combined=%.2f cite=%s (LLM skipped)',
+ count($refNos),
+ count($verified['claims']),
+ floatval($verified['combined_relevance_score']),
+ $citeGroupRefs
+ ));
+
+ return [
+ 'results' => $verified['results'],
+ 'claims' => $verified['claims'],
+ 'combined_relevance_score' => floatval($verified['combined_relevance_score']),
+ 'combined_reason' => (string)$verified['combined_reason'],
+ 'combined_author_comment' => (string)($verified['combined_author_comment'] ?? ''),
+ 'combined_locked' => true,
+ 'llm_skipped' => true,
+ ];
+ }
+
+ private function truncateText($text, $maxChars)
+ {
+ $text = (string)$text;
+ $maxChars = intval($maxChars);
+ if ($maxChars <= 0 || mb_strlen($text) <= $maxChars) {
+ return $text;
+ }
+
+ return mb_substr($text, 0, $maxChars);
+ }
+
/**
* @param array{results:array,request_failed?:bool,reason?:string} $fallback
* @return array{results:array,claims?:array,combined_relevance_score?:float,combined_reason?:string,request_failed?:bool,reason?:string}
@@ -115,17 +212,42 @@ class ReferenceRelevanceLlmService
array $referTypeMap,
$refCount,
array $fallback,
- array $fixedClaims = []
+ array $fixedClaims = [],
+ $fullCiteGroupRefs = ''
) {
+ $systemPrompt = $this->buildSystemPrompt();
+ $userPrompt = $this->buildUserPrompt(
+ $sectionText,
+ $localContext,
+ $referText,
+ $abstractText,
+ $citeGroupRefs,
+ $refCount,
+ $referTypeMap,
+ $fixedClaims,
+ $fullCiteGroupRefs
+ );
$payload = [
'model' => $this->model,
'temperature' => 0,
'max_tokens' => $this->resolveMaxTokens($refCount),
'messages' => [
- ['role' => 'system', 'content' => $this->buildSystemPrompt()],
- ['role' => 'user', 'content' => $this->buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $refCount, $referTypeMap, $fixedClaims)],
+ ['role' => 'system', 'content' => $systemPrompt],
+ ['role' => 'user', 'content' => $userPrompt],
],
];
+ \think\Log::info(sprintf(
+ 'ReferenceRelevanceLlm request prepare: url=%s timeout=%d refs=%d group=%d sys=%d user=%d max_tokens=%d compact=%d per_ref=%d',
+ $this->url,
+ intval($this->timeout),
+ intval($refCount),
+ intval($this->groupRefCount),
+ mb_strlen($systemPrompt),
+ mb_strlen($userPrompt),
+ intval($payload['max_tokens']),
+ $this->shouldUseCompactSystemPrompt() ? 1 : 0,
+ ($fullCiteGroupRefs !== '' && $refCount === 1) ? 1 : 0
+ ));
$content = $this->postChat($payload);
if ($content === null) {
@@ -145,7 +267,7 @@ class ReferenceRelevanceLlmService
return array_merge($fallback, ['reason' => 'LLM response JSON parse failed' . $truncHint . $savedHint]);
}
- $normalized = $this->normalizeResults($parsed, $citeGroupRefs, $localContext, $referText, $abstractText);
+ $normalized = $this->normalizeResults($parsed, $citeGroupRefs, $localContext, $referText, $abstractText, $referTypeMap);
$results = isset($normalized['results']) && is_array($normalized['results']) ? $normalized['results'] : [];
$combinedScore = floatval(isset($normalized['combined_relevance_score']) ? $normalized['combined_relevance_score'] : 0);
$combinedReason = (string)(isset($normalized['combined_reason']) ? $normalized['combined_reason'] : '');
@@ -239,11 +361,22 @@ class ReferenceRelevanceLlmService
'claims' => $claims,
'combined_relevance_score' => $combinedScore,
'combined_reason' => $combinedReason,
+ 'combined_author_comment' => $this->buildCombinedAuthorCommentFromReason($combinedScore, $combinedReason),
];
}
+ private function shouldUseCompactSystemPrompt()
+ {
+ // ≥4 篇逐篇时用压缩提示,降低单次请求体积与网关挂死概率
+ return intval($this->groupRefCount) >= 4;
+ }
+
private function buildSystemPrompt()
{
+ if ($this->shouldUseCompactSystemPrompt()) {
+ return $this->buildCompactSystemPrompt();
+ }
+
return <<<'PROMPT'
你是一名护理、医学、生物医学与科研期刊的资深学术编辑,正在执行「参考文献主题相关性校对」。
@@ -509,10 +642,63 @@ Claim 具体内容见顶层 `claims`,combined_reason **只用字母指代**(
PROMPT;
}
- private function buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $refCount = 0, array $referTypeMap = [], array $fixedClaims = [])
+ /**
+ * 大联合组压缩提示:保留评分硬规则与 JSON 契约,去掉长示例,降低网关挂死概率。
+ */
+ private function buildCompactSystemPrompt()
{
+ return <<<'PROMPT'
+你是资深学术编辑,执行「参考文献主题相关性校对」:判断引用处 claim 与各编号文献是否匹配。不是判断「是否同一疾病/领域」。
+
+【硬规则】
+1. 单条 score 只评该编号文献本身,不得因联合组抬高弱相关文献。
+2. 联合分写在顶层 combined_relevance_score/combined_reason,禁止写入 results 各条。
+3. 联合引用分摊:整段 Claim 由各篇分工覆盖;单篇主语一致且完整✔≥1 项 Claim → 单篇不低于 0.85(上限 0.85);仅「部分」→ 0.65;严禁因未覆盖他篇负责的 Claim 降到 0.45/0.25。
+4. 联合分按整组 Claim 覆盖并集定档:全覆盖 0.92~0.98;≥0.72→0.85;≥0.58→0.78;≥0.28→0.65。combined_reason 须点名无人覆盖的 Claim 与被多篇重复覆盖的 Claim。
+5. 主语/证据层级不对、类型不适配、几乎无覆盖 → 可给 0.45/0.25;主语一致且有任意✔/部分时禁止 0.25。
+6. 流行病学 claim 用机制原始研究 → 通常 0.45。同病不等于高分。
+7. 分值仅用:0.98/0.92/0.85/0.78/0.65/0.45/0.25/0.15;is_relevant = score>=0.65 ? 1 : 0。
+
+【流程】①拆 Claim 写入顶层 claims → ②文献证据 → ③Claim Mapping(✔/部分/✘) → ④覆盖率 → ⑤类型匹配 → ⑥分值与 reason。
+
+【输出】仅 JSON,无 markdown。reason/author_comment 仅中文。
+{
+ "cite_group_refs": "1,2",
+ "claims": {"A": "……", "B": "……"},
+ "combined_relevance_score": 0.85,
+ "combined_reason": "……",
+ "results": [
+ {"reference_no":1,"is_relevant":0,"relevance_score":0.45,"reason":"……","author_comment":"……"},
+ {"reference_no":2,"is_relevant":1,"relevance_score":0.85,"reason":"……","author_comment":""}
+ ]
+}
+results 每条仅含上述 5 字段。reason ≤120 字,须含 Claim 覆盖字母标注。combined_reason ≤180 字。
+author_comment:score<=0.65 时 80–120 字委婉建议(可替换编号文献或调整句子);score>0.65 时 ""。禁止补充/新增文献措辞,改为替换/改引。
+PROMPT;
+ }
+
+ private function buildUserPrompt(
+ $sectionText,
+ $localContext,
+ $referText,
+ $abstractText,
+ $citeGroupRefs,
+ $refCount = 0,
+ array $referTypeMap = [],
+ array $fixedClaims = [],
+ $fullCiteGroupRefs = ''
+ ) {
$parts = ["【正文节 t_article_main】\n" . $sectionText];
- if (trim((string)$citeGroupRefs) !== '') {
+ $fullCiteGroupRefs = trim((string)$fullCiteGroupRefs);
+ $citeGroupRefs = trim((string)$citeGroupRefs);
+ $perRefInGroup = ($fullCiteGroupRefs !== ''
+ && $refCount === 1
+ && $this->countCiteGroupRefs($fullCiteGroupRefs) > 1);
+ if ($perRefInGroup) {
+ $parts[] = "【引用文献组 cite_group_refs】{$fullCiteGroupRefs}(联合引用,共"
+ . $this->countCiteGroupRefs($fullCiteGroupRefs) . "篇)";
+ $parts[] = "【本批仅评】文献 {$citeGroupRefs}(仅输出该编号 1 条 results;联合结论由系统汇总,不必写完整组 combined_reason)";
+ } elseif ($citeGroupRefs !== '') {
$mode = strpos($citeGroupRefs, ',') !== false ? '联合引用' : '单独引用';
$parts[] = "【引用文献组 cite_group_refs】{$citeGroupRefs}({$mode})";
}
@@ -541,7 +727,12 @@ PROMPT;
} elseif ($this->allReferType($referTypeMap, 'book')) {
$tail .= ' 注意:本组全部为图书/教材(无 DOI、无外部摘要)。请走「书目推断」轨:据书名、副标题、作者专业领域、ISBN、出版社、版次判断文献类型与主题,缺摘要不得默认 0.25。';
}
- if ($refCount >= 5) {
+ if ($perRefInGroup) {
+ $tail .= sprintf(
+ ' 本批仅评文献 %s(联合组分篇校对):必须且仅输出 1 条 results;reason ≤180 字,须含 Claim 覆盖字母标注(A✔/部分/✘)。按联合引用分摊原则评分:主语一致且完整✔至少 1 项 Claim → 单篇不低于 0.85(上限 0.85),严禁因未覆盖他篇负责的 Claim 而降到 0.45/0.25;仅「部分」则 0.65。顶层 combined_* 可与单条一致(整组联合结论由系统汇总,不必写完整组分工)。若无【已确定的 Claim 列表】则先拆解 claims;若有则必须沿用。',
+ $citeGroupRefs
+ );
+ } elseif ($refCount >= 5) {
$tail .= sprintf(
' 本组共 %d 篇联合引用:**最硬要求:必须输出全部 %d 条 results,缺任何一条视为无效**。为防输出截断:每条 reason ≤120 字、顶层 combined_reason ≤180 字,但仍须点名各 Claim 覆盖(哪几条✔/部分/✘),不可只写"部分相关";先保证条数完整。',
$refCount,
@@ -562,7 +753,7 @@ PROMPT;
} else {
$tail .= ' 单条引用:reason ≤220 字,点名各 Claim 覆盖与分值依据;顶层 combined_* 与单条一致。';
}
- if ($refCount > 1) {
+ if ($refCount > 1 && !$perRefInGroup) {
$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;
@@ -640,10 +831,13 @@ PROMPT;
private function resolveMaxTokens($refCount)
{
$refCount = max(1, intval($refCount));
- // reason 已压缩;过高 max_tokens 会拖慢本地推理排队,按条数给够即可
- $dynamic = min(8192, max(3072, $refCount * 900 + 1200));
- if ($refCount >= 4) {
- $dynamic = max($dynamic, 5120);
+ // 过高 max_tokens 会拖慢本地推理;分块后单次篇数少,给够即可
+ if ($refCount <= 2) {
+ $dynamic = 2560;
+ } elseif ($refCount <= 4) {
+ $dynamic = 4096;
+ } else {
+ $dynamic = min(8192, $refCount * 900 + 1200);
}
if ($this->maxTokens > 0) {
return $this->maxTokens;
@@ -710,9 +904,10 @@ PROMPT;
}
/**
- * 大引用组拆批调用 LLM,合并逐篇结果后再计算联合分。
+ * 大引用组拆批调用 LLM:先按编号取块,再在块内截断;单块失败不丢弃已成功块。
*
- * @return array{results:array,claims?:array,combined_relevance_score?:float,combined_reason?:string,request_failed?:bool,reason?:string}
+ * @param callable|null $onChunkDone function(array $part, array $chunkRefNos): void 每块成功后回调(用于立即落库)
+ * @return array{results:array,claims?:array,combined_relevance_score?:float,combined_reason?:string,request_failed?:bool,reason?:string,partial?:bool}
*/
private function checkRelevanceByChunks(
$sectionText,
@@ -722,7 +917,8 @@ PROMPT;
$citeGroupRefs,
array $referTypeMap,
$refCount,
- $chunkSize
+ $chunkSize,
+ $onChunkDone = null
) {
$fallback = [
'results' => [],
@@ -734,20 +930,27 @@ PROMPT;
return array_merge($fallback, ['reason' => 'Empty cite_group_refs']);
}
- $chunkSize = max(2, intval($chunkSize));
+ $chunkSize = max(1, intval($chunkSize));
$chunks = array_chunk($refNums, $chunkSize);
$allResults = [];
$claims = [];
- $combinedScore = 0.0;
- $combinedReason = '';
+ $failedReasons = [];
+ // ≥4 篇逐篇时:联合结论完全由程序汇总,不采用各批 LLM 的 combined_*
+ $programmaticCombined = ($chunkSize === 1 && $refCount > 3);
foreach ($chunks as $chunk) {
$chunkRefs = implode(',', $chunk);
+ // 先按编号取块,再截断——保证本块文献文本完整进入预算
$chunkRefer = $this->filterRefBlocks($referText, $chunk);
- if ($chunkRefer === '') {
- $chunkRefer = $referText;
- }
$chunkAbstract = $this->filterRefBlocks($abstractText, $chunk);
+ if ($chunkRefer === '') {
+ $failedReasons[] = sprintf('chunk[%s] missing refer blocks after split', $chunkRefs);
+ \think\Log::warning('ReferenceRelevanceLlm: empty refer blocks for chunk=' . $chunkRefs);
+ continue;
+ }
+ $chunkRefer = $this->truncateText($chunkRefer, $this->maxReferChars);
+ $chunkAbstract = $this->truncateText($chunkAbstract, $this->maxAbstractChars);
+
$chunkTypeMap = [];
foreach ($chunk as $refNo) {
if (isset($referTypeMap[$refNo])) {
@@ -764,78 +967,352 @@ PROMPT;
$chunkTypeMap,
count($chunk),
$fallback,
- $claims
+ $claims,
+ $programmaticCombined ? $citeGroupRefs : ''
);
if (!empty($part['request_failed']) || empty($part['results'])) {
$reason = isset($part['reason']) ? (string)$part['reason'] : 'LLM split batch failed';
- return array_merge($fallback, ['reason' => $reason]);
+ $failedReasons[] = sprintf('chunk[%s] %s', $chunkRefs, $reason);
+ \think\Log::warning(sprintf(
+ 'ReferenceRelevanceLlm: chunk failed cite=%s reason=%s',
+ $chunkRefs,
+ $reason
+ ));
+ continue;
}
if (empty($claims) && !empty($part['claims']) && is_array($part['claims'])) {
$claims = $part['claims'];
}
- if ($combinedScore <= 0 && isset($part['combined_relevance_score'])) {
- $combinedScore = floatval($part['combined_relevance_score']);
- $combinedReason = (string)(isset($part['combined_reason']) ? $part['combined_reason'] : '');
- }
+ $chunkRows = [];
foreach ($part['results'] as $row) {
$refNo = intval(isset($row['reference_no']) ? $row['reference_no'] : 0);
if ($refNo > 0 && !isset($allResults[$refNo])) {
$allResults[$refNo] = $row;
+ $chunkRows[] = $row;
+ }
+ }
+ if (!empty($chunkRows) && is_callable($onChunkDone)) {
+ try {
+ $callbackPart = [
+ 'results' => $chunkRows,
+ 'claims' => $claims,
+ ];
+ if ($programmaticCombined) {
+ // 暂不写最终联合结论;落库后由 refreshGroupCombinedFields 程序汇总
+ $callbackPart['combined_relevance_score'] = 0;
+ $callbackPart['combined_reason'] = '';
+ } else {
+ $callbackPart['combined_relevance_score'] = floatval(isset($part['combined_relevance_score']) ? $part['combined_relevance_score'] : 0);
+ $callbackPart['combined_reason'] = (string)(isset($part['combined_reason']) ? $part['combined_reason'] : '');
+ }
+ call_user_func($onChunkDone, $callbackPart, $chunk);
+ } catch (\Throwable $e) {
+ \think\Log::error('ReferenceRelevanceLlm onChunkDone: ' . $e->getMessage());
}
}
}
ksort($allResults, SORT_NUMERIC);
$results = array_values($allResults);
- if (count($results) < count($refNums)) {
- return array_merge($fallback, [
- 'reason' => sprintf(
- 'LLM split batch incomplete: got %d/%d for cite_group_refs=%s',
- count($results),
- count($refNums),
- $citeGroupRefs
- ),
- ]);
+ if (empty($results)) {
+ $msg = !empty($failedReasons)
+ ? implode('; ', array_slice($failedReasons, 0, 3))
+ : 'LLM split batch failed';
+ return array_merge($fallback, ['reason' => $msg]);
}
- 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(
- $combinedScore,
- $this->levelFromScore($combinedScore)
- );
- }
- $combinedReason = $this->appendGroupCoverageNote($combinedReason, $coverage);
- } elseif (count($results) === 1) {
- $combinedScore = floatval($results[0]['relevance_score'] ?? 0);
- $combinedReason = (string)($results[0]['reason'] ?? '');
- }
+ $combined = $this->rebuildCombinedFromResults($results);
+ $combinedScore = floatval($combined['combined_relevance_score']);
+ $combinedReason = (string)$combined['combined_reason'];
+ $combinedAuthorComment = (string)($combined['combined_author_comment'] ?? '');
+ $partial = count($results) < count($refNums);
\think\Log::info(sprintf(
- 'ReferenceRelevanceLlm: split %d refs into %d batches (chunk=%d) cite_group_refs=%s',
+ 'ReferenceRelevanceLlm: split %d refs into %d batches (chunk=%d) got=%d partial=%d programmatic_combined=%d cite_group_refs=%s',
$refCount,
count($chunks),
$chunkSize,
+ count($results),
+ $partial ? 1 : 0,
+ $programmaticCombined ? 1 : 0,
$citeGroupRefs
));
- return [
+ $out = [
'results' => $results,
'claims' => $claims,
'combined_relevance_score' => $combinedScore,
'combined_reason' => $combinedReason,
+ 'combined_author_comment' => $combinedAuthorComment,
];
+ if ($partial) {
+ $out['partial'] = true;
+ $out['reason'] = sprintf(
+ 'LLM split batch partial: got %d/%d; %s',
+ count($results),
+ count($refNums),
+ !empty($failedReasons) ? implode('; ', array_slice($failedReasons, 0, 2)) : 'some chunks missing'
+ );
+ }
+
+ return $out;
+ }
+
+ /**
+ * 与单条 author_comment 同一套规则:score > 0.65 返回空;否则委婉批注。
+ */
+ public function buildAuthorCommentByScore($score, $reason, $seedComment = '', $maxChars = 160)
+ {
+ return $this->normalizeAuthorComment($seedComment, $score, $reason, $maxChars);
+ }
+
+ /**
+ * 组合批注:把 combined_reason 委婉改写即可(规则对齐 author_comment)。
+ * score > 0.65 返回空。
+ */
+ public function buildCombinedAuthorCommentFromReason($score, $combinedReason, $maxChars = 800)
+ {
+ $score = floatval($score);
+ if ($score > 0.65 + 0.001) {
+ return '';
+ }
+ $soft = $this->softenCombinedReasonTone((string)$combinedReason);
+ // 作为 seed 走与 author_comment 相同的清洗/收尾
+ return $this->normalizeAuthorComment($soft, $score, '', $maxChars);
+ }
+
+ /**
+ * 将 combined_reason 转为作者可读语气:去掉技术符号,措辞委婉,保留原意。
+ */
+ private function softenCombinedReasonTone($reason)
+ {
+ $text = trim((string)$reason);
+ if ($text === '') {
+ return '';
+ }
+
+ $text = preg_replace('/整组统计核验([^)]*)[::]\s*/u', '该处对纳入研究的整组核对显示:', $text);
+ $text = preg_replace('/逐篇校对汇总[::]\s*/u', '该处多篇文献汇总核对显示:', $text);
+ $text = preg_replace('/【需核实】/u', '建议优先核实:', $text);
+ $text = preg_replace('/建议优先核对上述编号的书目\/元数据或正文统计数字。?/u', '建议优先核对上述编号的书目信息或正文统计数字。', $text);
+ $text = preg_replace('/暂缺作者单位国别与语种信息,影响国家数\/语言\/区域分布核验/u', '暂缺作者单位国别与语种信息,相关统计数字似需再核', $text);
+ $text = preg_replace('/暂缺作者单位国别,影响国家数\/区域分布核验/u', '暂缺作者单位国别,国家数与区域分布数字似需再核', $text);
+ $text = preg_replace('/暂缺语种信息,影响语言构成核验/u', '暂缺语种信息,语言构成数字似需再核', $text);
+ $text = preg_replace('/仅为期刊类型、尚缺正式发表证据/u', '似乎尚缺正式发表证据', $text);
+ $text = preg_replace('/有未发表标记(in press\/submitted 等)/u', '似乎带有未正式发表标记', $text);
+ $text = preg_replace('/非期刊文献(图书\/预印本\/会议等)/u', '似乎并非已发表期刊论文', $text);
+ $text = preg_replace('/下列编号归属该区域需核对/u', '下列编号归属该区域,似需再核', $text);
+ $text = preg_replace('/【整组覆盖】/u', '', $text);
+ $text = preg_replace('/Claim\s*覆盖[::]?\s*/u', '', $text);
+ $text = preg_replace('/\b[A-E]\s*[✔✘?]/u', '', $text);
+ $text = preg_replace('/[✔✘?]/u', '', $text);
+ $text = preg_replace('/通过\s*(\d+)\s*项、不符\s*(\d+)\s*项、元数据不足\s*(\d+)\s*项。?/u', '其中约$1项较为吻合、$2项似乎尚不完全吻合、$3项因文献信息不足暂难确认。', $text);
+ $text = preg_replace('/程序按作者单位核到/u', '按所引文献作者单位汇总似乎为', $text);
+ $text = preg_replace('/程序核到/u', '按所引文献书目汇总似乎为', $text);
+ $text = preg_replace('/已核到/u', '目前按书目汇总可见', $text);
+ $text = preg_replace('/正文所称的/u', '正文所写的', $text);
+ $text = preg_replace('/与正文所称/u', '与正文所写', $text);
+ $text = preg_replace('/正文称/u', '正文写为', $text);
+ $text = preg_replace('/([一-龥A-Za-z]+)\s*称\s*(\d+)\s*实核\s*(\d+)/u', '$1正文写为$2、汇总似乎为$3', $text);
+ $text = preg_replace('/以下项实核多于正文,缺失文献无法解释——/u', '其中', $text);
+ $text = preg_replace('/实核多于正文,缺失文献无法解释/u', '与正文似乎尚不完全吻合,且似难以仅用缺失文献完全解释', $text);
+ $text = preg_replace('/无法解释/u', '似难以完全对应', $text);
+ $text = preg_replace('/多出\s*(\d+)/u', '约多出$1', $text);
+ $text = preg_replace('/未取到作者单位国别/u', '暂缺作者单位国别信息', $text);
+ $text = preg_replace('/未取到语种元数据/u', '暂缺语种信息', $text);
+ $text = preg_replace('/需补齐元数据后确认/u', '似需补齐相应文献信息后再确认', $text);
+ $text = preg_replace('/排除预印本\/会议\/未发表标记,并经 PubMed 收录或刊名\+年份\+卷期页\/DOI 核验/u', '经书目与收录信息核对', $text);
+ $text = preg_replace('/建议按上述不符项核对正文数字,或补正相应文献编号。?/u', '建议核对正文中的相关数字,或酌情调整该句表述。', $text);
+ $text = preg_replace('/元数据不足项需补齐[^。]*。?/u', '部分文献信息似需补齐后再复核。', $text);
+ $text = preg_replace('/此处编号是被统计的纳入研究本身,不逐篇做语义相关性判断[::]?\s*/u', '', $text);
+ $text = preg_replace('/联合分\s*[01](?:\.\d+)?/u', '', $text);
+ $text = preg_replace('/\s*[;;]\s*/u', ';', $text);
+ $text = preg_replace('/[;;]{2,}/u', ';', $text);
+ $text = preg_replace('/\s{2,}/u', ' ', $text);
+ $text = preg_replace('/^[\s;;,,。]+|[\s;;,,。]+$/u', '', $text);
+
+ if ($text === '') {
+ return '该处正文表述与所引文献汇总的对应关系似乎尚不够充分。建议核对正文相关内容,或酌情调整该句,使引用与文献证据保持一致';
+ }
+ if (!preg_match('/建议|似可|不妨|可考虑/u', $text)) {
+ $text .= '。建议核对正文中的相关数字或表述,或酌情调整该句,使引用内容与文献证据保持一致';
+ }
+
+ return $text;
+ }
+
+ /**
+ * 根据已落库/已返回的单篇结果重算联合分(供分块落库后刷新整组 combined_*)。
+ *
+ * @param array $results 元素含 reference_no/relevance_score/reason/is_relevant
+ * @return array{combined_relevance_score:float,combined_reason:string,combined_author_comment:string}
+ */
+ public function rebuildCombinedFromResults(array $results)
+ {
+ $results = array_values($results);
+ if (empty($results)) {
+ return [
+ 'combined_relevance_score' => 0.0,
+ 'combined_reason' => '',
+ 'combined_author_comment' => '',
+ ];
+ }
+ if (count($results) === 1) {
+ $score = floatval($results[0]['relevance_score'] ?? 0);
+ $reason = (string)($results[0]['reason'] ?? '');
+
+ return [
+ 'combined_relevance_score' => $score,
+ 'combined_reason' => $reason,
+ 'combined_author_comment' => $this->buildCombinedAuthorCommentFromReason($score, $reason),
+ ];
+ }
+
+ $bands = $this->getScoreBands();
+ $coverage = $this->summarizeGroupCoverage($results);
+ $combinedScore = $this->enforceCombinedAgainstSingles($results, $coverage['floor'], $bands);
+ if ($this->maxSingleRelevanceScore($results) >= 0.65 - 0.001
+ && $coverage['floor'] > $combinedScore) {
+ $combinedScore = $coverage['floor'];
+ }
+ if ($combinedScore <= 0) {
+ $combinedScore = $this->maxSingleRelevanceScore($results);
+ }
+ $penalty = $this->applyWeakMajorityPenalty($combinedScore, $results, $coverage);
+ $combinedScore = floatval($penalty['score']);
+ $combinedReason = $this->buildProgrammaticCombinedReason(
+ $results,
+ $combinedScore,
+ $coverage,
+ (string)$penalty['note']
+ );
+
+ return [
+ 'combined_relevance_score' => floatval($combinedScore),
+ 'combined_reason' => $combinedReason,
+ 'combined_author_comment' => $this->buildCombinedAuthorCommentFromReason($combinedScore, $combinedReason),
+ ];
+ }
+
+ /**
+ * 由各篇单条结果程序汇总 combined_reason(重复覆盖 / Claim 缺口等)。
+ */
+ private function buildProgrammaticCombinedReason(array $results, $combinedScore, array $coverage, $penaltyNote = '')
+ {
+ $high = $partial = $weak = 0;
+ $weakRefs = [];
+ foreach ($results as $row) {
+ $score = floatval(isset($row['relevance_score']) ? $row['relevance_score'] : 0);
+ $refNo = intval(isset($row['reference_no']) ? $row['reference_no'] : 0);
+ if ($score >= 0.85 - 0.001) {
+ $high++;
+ } elseif ($score >= 0.65 - 0.001) {
+ $partial++;
+ } else {
+ $weak++;
+ if ($refNo > 0) {
+ $hint = $this->briefVerifyHintFromReason((string)(isset($row['reason']) ? $row['reason'] : ''));
+ $weakRefs[] = $hint !== ''
+ ? sprintf('文献%d(%s)', $refNo, $hint)
+ : sprintf('文献%d', $refNo);
+ }
+ }
+ }
+ $head = sprintf(
+ '逐篇校对汇总:共%d篇(高度相关%d、部分相关%d、弱/不相关%d);联合分%.2f。',
+ count($results),
+ $high,
+ $partial,
+ $weak,
+ floatval($combinedScore)
+ );
+ $penaltyNote = trim((string)$penaltyNote);
+ if ($penaltyNote !== '') {
+ $head .= ' ' . $penaltyNote;
+ }
+ $reason = $this->appendGroupCoverageNote($head, $coverage);
+ if (!empty($weakRefs)) {
+ $shown = $weakRefs;
+ $suffix = '';
+ if (count($shown) > 8) {
+ $shown = array_slice($shown, 0, 8);
+ $suffix = '等';
+ }
+ $reason .= sprintf(
+ ' 【需核实】%s%s:单条相关度偏低,建议核对正文主张与该编号文献证据是否匹配,或酌情替换相应编号文献/调整该句。',
+ implode(';', $shown),
+ $suffix
+ );
+ }
+
+ return $reason;
+ }
+
+ /**
+ * 从单条 reason 抽一句短核实提示(去掉 Claim 符号与过长细节)。
+ */
+ private function briefVerifyHintFromReason($reason)
+ {
+ $reason = trim((string)$reason);
+ if ($reason === '') {
+ return '';
+ }
+ $reason = preg_replace('/Claim覆盖[::].*/u', '', $reason);
+ $reason = preg_replace('/[A-E]\s*[✔✘?]|[✔✘?]/u', '', $reason);
+ $reason = preg_replace('/\s{2,}/u', ' ', $reason);
+ $reason = trim($reason, " ;;,,。");
+ if ($reason === '') {
+ return '与正文主张对应不足';
+ }
+
+ return mb_substr($reason, 0, 36, 'UTF-8');
+ }
+
+ /**
+ * 大联合组弱相关占比过高时,压低联合分,避免 1 篇中等相关抬高整组结论。
+ * 例外:仅 1 条 Claim 且已完整覆盖时,允许维持 0.65。
+ *
+ * @return array{score:float,note:string}
+ */
+ private function applyWeakMajorityPenalty($combinedScore, array $results, array $coverage)
+ {
+ $combinedScore = floatval($combinedScore);
+ $totalRefs = count($results);
+ if ($totalRefs < 10) {
+ return ['score' => $combinedScore, 'note' => ''];
+ }
+
+ $supportCount = 0;
+ foreach ($results as $row) {
+ $score = floatval(isset($row['relevance_score']) ? $row['relevance_score'] : 0);
+ if ($score >= 0.65 - 0.001) {
+ $supportCount++;
+ }
+ }
+
+ // >=80% 为弱相关(<=20% 支撑)时触发惩罚
+ if ($supportCount * 5 > $totalRefs) {
+ return ['score' => $combinedScore, 'note' => ''];
+ }
+
+ $singleClaimFullyCovered = intval(isset($coverage['total']) ? $coverage['total'] : 0) === 1
+ && intval(isset($coverage['full']) ? $coverage['full'] : 0) === 1;
+ $cap = $singleClaimFullyCovered ? 0.65 : 0.45;
+ if ($combinedScore <= $cap + 0.001) {
+ return ['score' => $combinedScore, 'note' => ''];
+ }
+
+ $score = $this->snapScore($cap, $this->getScoreBands());
+ $note = sprintf(
+ '弱相关占比过高(%d/%d 文献得分<0.65),联合分按规则下调至 %.2f。',
+ $totalRefs - $supportCount,
+ $totalRefs,
+ $score
+ );
+
+ return ['score' => $score, 'note' => $note];
}
/**
@@ -933,7 +1410,7 @@ PROMPT;
return $this->snapScore(($scores[$mid - 1] + $scores[$mid]) / 2, $bands);
}
- private function normalizeResults(array $parsed, $defaultCiteGroupRefs, $localContext = '', $referText = '', $abstractText = '')
+ private function normalizeResults(array $parsed, $defaultCiteGroupRefs, $localContext = '', $referText = '', $abstractText = '', array $referTypeMap = [])
{
$rows = [];
if (isset($parsed['results']) && is_array($parsed['results'])) {
@@ -1019,17 +1496,113 @@ PROMPT;
];
}
- $groupCombined = $this->resolveGroupCombinedFields($parsed, $rows, $out, $citeGroupRefs, $bands);
$claims = $this->normalizeClaims(isset($parsed['claims']) ? $parsed['claims'] : []);
+ $out = $this->applyStudySetMetadataClaimOverride($out, $claims, $localContext, $referTypeMap, $bands);
+ $groupCombined = $this->resolveGroupCombinedFields($parsed, $rows, $out, $citeGroupRefs, $bands);
return [
'results' => $out,
'claims' => $claims,
'combined_relevance_score' => floatval($groupCombined['combined_relevance_score']),
'combined_reason' => (string)$groupCombined['combined_reason'],
+ 'combined_author_comment' => (string)($groupCombined['combined_author_comment'] ?? ''),
];
}
+ /**
+ * 枚举式引用(正文统计「纳入的 N 项研究」的出版形式/国家数/语言/区域分布):
+ * 这些编号是被统计的对象本身,不是论点的证据来源,逐篇做主题相关性判断无意义。
+ * 此时由程序核验文献类型并整体重写 reason,避免 LLM 给出 A✘ 与程序 A✔ 自相矛盾。
+ */
+ private function applyStudySetMetadataClaimOverride(array $outRows, array $claims, $localContext, array $referTypeMap, array $bands)
+ {
+ if (empty($outRows)) {
+ return $outRows;
+ }
+ if (!$this->isStudySetMetadataClaimSet($claims, $localContext)) {
+ return $outRows;
+ }
+
+ $allJournal = !empty($referTypeMap);
+ foreach ($outRows as $row) {
+ $refNo = intval(isset($row['reference_no']) ? $row['reference_no'] : 0);
+ $info = isset($referTypeMap[$refNo]) ? $referTypeMap[$refNo] : null;
+ $type = is_array($info) ? (string)($info['type'] ?? '') : (string)$info;
+ if ($type !== 'journal') {
+ $allJournal = false;
+ break;
+ }
+ }
+
+ $score = $this->snapScore($allJournal ? 0.92 : 0.65, $bands);
+ foreach ($outRows as &$row) {
+ $refNo = intval(isset($row['reference_no']) ? $row['reference_no'] : 0);
+ $row['relevance_score'] = $score;
+ $row['is_relevant'] = 1;
+ if ($allJournal) {
+ $row['reason'] = sprintf(
+ '枚举式引用:文献%d 是正文所统计的纳入研究之一,而非论点的证据来源。程序核验其文献类型为已发表期刊论文(A✔)。国家/地区数、语言构成、区域分布属整组统计项,需按整组元数据核验,不计入单篇覆盖。',
+ $refNo
+ );
+ $row['author_comment'] = '';
+ } else {
+ $row['reason'] = sprintf(
+ '枚举式引用:文献%d 是正文所统计的纳入研究之一,但程序未能确认其为已发表期刊论文(A 存疑,可能为图书/预印本/会议文献)。其余统计项需按整组元数据核验。',
+ $refNo
+ );
+ $row['author_comment'] = $this->normalizeAuthorComment(
+ '该处正文在统计纳入研究的出版形式,本条文献的书目信息似乎未能确认为正式发表的期刊论文,建议核对该编号的期刊名、卷期页码或 DOI,或酌情调整该句表述,使统计口径与文献实际情况保持一致。',
+ $score,
+ $row['reason']
+ );
+ }
+ }
+ unset($row);
+
+ return $outRows;
+ }
+
+ /**
+ * 判断本引用位置的 Claim 是否属于「纳入研究集合的统计描述」。
+ */
+ private function isStudySetMetadataClaimSet(array $claims, $localContext)
+ {
+ $text = '';
+ foreach ($claims as $t) {
+ $text .= ' ' . (string)$t;
+ }
+ $text .= ' ' . (string)$localContext;
+ $text = trim($text);
+ if ($text === '') {
+ return false;
+ }
+
+ $hits = 0;
+ if (preg_match('/included\s+stud(?:y|ies)/i', $text)
+ || preg_match('/纳入(?:的)?研究/u', $text)) {
+ $hits++;
+ }
+ if (preg_match('/published\s+journal\s+articles?/i', $text)
+ || preg_match('/(?:发表于|已发表).*期刊/u', $text)) {
+ $hits++;
+ }
+ if (preg_match('/conducted\s+across\s+\d+\s+countr/i', $text)
+ || preg_match('/\d+\s*个?(?:国家|地区)/u', $text)) {
+ $hits++;
+ }
+ if (preg_match('/published\s+in\s+english/i', $text)
+ || preg_match('/in\s+chinese/i', $text)
+ || preg_match('/语言(?:构成|为)/u', $text)) {
+ $hits++;
+ }
+ if (preg_match('/\b(?:studies|篇)\s*from\s+(?:Europe|Africa|Asia|Oceania)/i', $text)
+ || preg_match('/(?:欧洲|非洲|亚洲|大洋洲|北美|南美)/u', $text)) {
+ $hits++;
+ }
+
+ return $hits >= 2;
+ }
+
/**
* 归一化顶层 claims:键 A/B/C…,值为各 Claim 中文具体内容。
*/
@@ -1135,6 +1708,7 @@ PROMPT;
return [
'combined_relevance_score' => $combinedScore,
'combined_reason' => $combinedReason,
+ 'combined_author_comment' => $this->buildCombinedAuthorCommentFromReason($combinedScore, $combinedReason),
];
}
@@ -1876,7 +2450,7 @@ PROMPT;
return '';
}
- private function normalizeAuthorComment($authorComment, $score, $reason)
+ private function normalizeAuthorComment($authorComment, $score, $reason, $maxChars = 160)
{
$score = floatval($score);
if ($score > 0.65 + 0.001) {
@@ -1887,28 +2461,28 @@ PROMPT;
if ($authorComment !== '') {
$authorComment = $this->sanitizeAuthorCommentText($authorComment);
if ($authorComment !== '') {
- return $this->finalizeAuthorComment($authorComment);
+ return $this->finalizeAuthorComment($authorComment, $maxChars);
}
}
$fallback = '该处参考文献与正文表述的对应关系似乎尚不够充分,文献侧重点与正文核心论点略有不同。建议替换相应编号文献以更直接支持此处表述,或酌情调整该句,使引用内容与文献证据保持一致。';
$reason = trim((string)$reason);
if ($reason === '') {
- return $this->finalizeAuthorComment($fallback);
+ return $this->finalizeAuthorComment($fallback, $maxChars);
}
// 去掉显式分数结论,保留给作者可读的委婉批注
$reason = preg_replace('/Claim覆盖[::].*/u', '', $reason);
$reason = preg_replace('/故\s*(?:联合分?)?\s*[01](?:\.\d+)?[。.]?/u', '', $reason);
$reason = trim((string)$reason);
if ($reason === '') {
- return $this->finalizeAuthorComment($fallback);
+ return $this->finalizeAuthorComment($fallback, $maxChars);
}
$reason = $this->sanitizeAuthorCommentText($reason);
if ($reason === '') {
- return $this->finalizeAuthorComment($fallback);
+ return $this->finalizeAuthorComment($fallback, $maxChars);
}
- return $this->finalizeAuthorComment($reason);
+ return $this->finalizeAuthorComment($reason, $maxChars);
}
private function sanitizeAuthorCommentText($text)
@@ -1937,15 +2511,25 @@ PROMPT;
$text = preg_replace('/完全不(?:符|相关|匹配)/u', '契合度略显不足', $text);
$text = preg_replace('/无法支持/u', '对该表述支持稍显不足', $text);
$text = preg_replace('/\s{2,}/u', ' ', $text);
- $text = trim((string)$text, " \t\n\r\0\x0B;;,,");
+ $text = preg_replace('/^[\s;;,,。]+|[\s;;,,。]+$/u', '', (string)$text);
return $text;
}
- private function finalizeAuthorComment($text)
+ private function finalizeAuthorComment($text, $maxChars = 160)
{
- $text = mb_substr(trim((string)$text), 0, 160);
- $text = trim($text, " \t\n\r\0\x0B;;,,。");
+ $text = trim((string)$text);
+ if ($text === '') {
+ return '';
+ }
+ if (!mb_check_encoding($text, 'UTF-8')) {
+ $converted = @iconv('UTF-8', 'UTF-8//IGNORE', $text);
+ $text = is_string($converted) ? $converted : preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $text);
+ }
+ $maxChars = max(80, intval($maxChars));
+ $text = mb_substr($text, 0, $maxChars, 'UTF-8');
+ // 禁止把中文标点放进 trim() 字符表:PHP trim 按字节剥离,会拆坏多字节汉字
+ $text = preg_replace('/^[\s;;,,。]+|[\s;;,,。]+$/u', '', $text);
if ($text === '') {
return '';
}
@@ -1966,6 +2550,64 @@ PROMPT;
}
private function postChat(array $payload)
+ {
+ $this->lastPostError = '';
+ $maxAttempts = max(1, intval(Env::get('promotion.relevance_llm_retries', 1)));
+ $lastError = '';
+ for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
+ $content = $this->postChatOnce($payload, $attempt, $maxAttempts);
+ if ($content !== null) {
+ return $content;
+ }
+ $lastError = $this->lastPostError;
+ $retryable = $this->isRetryableLlmError($lastError);
+ if (!$retryable || $attempt >= $maxAttempts) {
+ break;
+ }
+ $sleepSec = min(8, $attempt * 2);
+ \think\Log::warning(sprintf(
+ 'ReferenceRelevanceLlm: retryable failure attempt=%d/%d sleep=%ds err=%s',
+ $attempt,
+ $maxAttempts,
+ $sleepSec,
+ $lastError
+ ));
+ sleep($sleepSec);
+ }
+ if ($lastError !== '') {
+ $this->lastPostError = $lastError;
+ }
+
+ return null;
+ }
+
+ private function isRetryableLlmError($error)
+ {
+ $error = (string)$error;
+ if ($error === '') {
+ return false;
+ }
+ $needles = [
+ 'timed out',
+ 'Operation timed out',
+ '0 bytes received',
+ 'Empty reply from server',
+ 'Failed to connect',
+ 'Connection reset',
+ 'HTTP 502',
+ 'HTTP 503',
+ 'HTTP 504',
+ ];
+ foreach ($needles as $n) {
+ if (stripos($error, $n) !== false) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function postChatOnce(array $payload, $attempt = 1, $maxAttempts = 1)
{
$this->lastPostError = '';
try {
@@ -1988,9 +2630,11 @@ PROMPT;
$this->lastPostError = 'LLM curl error: ' . curl_error($ch);
$errno = intval(curl_errno($ch));
\think\Log::warning(sprintf(
- 'ReferenceRelevanceLlm: %s; errno=%d; timing={%s}',
+ 'ReferenceRelevanceLlm: %s; errno=%d; attempt=%d/%d; timing={%s}',
$this->lastPostError,
$errno,
+ $attempt,
+ $maxAttempts,
$timingSummary
));
curl_close($ch);
@@ -1999,8 +2643,10 @@ PROMPT;
$httpCode = intval(isset($info['http_code']) ? $info['http_code'] : 0);
curl_close($ch);
\think\Log::info(sprintf(
- 'ReferenceRelevanceLlm request completed: http=%d; timing={%s}',
+ 'ReferenceRelevanceLlm request completed: http=%d; attempt=%d/%d; timing={%s}',
$httpCode,
+ $attempt,
+ $maxAttempts,
$timingSummary
));
if ($httpCode < 200 || $httpCode >= 300) {