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) {