diff --git a/application/api/controller/References.php b/application/api/controller/References.php index 57a4ee5d..165a278b 100644 --- a/application/api/controller/References.php +++ b/application/api/controller/References.php @@ -1503,6 +1503,29 @@ class References extends Base } } + /** + * 仅重跑某篇文章下 status=3(校对失败)的相关性记录(不清空,不抓摘要,不清洗文献内容) + * 联合引用组会整组重跑。 + * POST: p_article_id + */ + public function referenceRelevanceCheckRecheckFailedAI() + { + $aParam = $this->request->post(); + if (empty($aParam)) { + $aParam = $this->request->param(); + } + $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); + if ($iPArticleId <= 0) { + return jsonError('p_article_id is required'); + } + try { + $result = (new ReferenceRelevanceCheckService())->recheckFailedOnlyByArticle($iPArticleId); + return jsonSuccess($result); + } catch (\Exception $e) { + return jsonError($e->getMessage()); + } + } + /** * @deprecated 支撑力度校对已下线,请使用 allReferenceCheckAI(主题相关性校对) */ diff --git a/application/common/ReferenceRelevanceCheckService.php b/application/common/ReferenceRelevanceCheckService.php index 049602b0..788bdf9a 100644 --- a/application/common/ReferenceRelevanceCheckService.php +++ b/application/common/ReferenceRelevanceCheckService.php @@ -204,6 +204,77 @@ class ReferenceRelevanceCheckService ]; } + /** + * 仅重新校对某篇文章下 status=3(失败)的记录;不刷新 refer_text / 文献摘要,只重置结果字段后入队。 + * 联合引用组会整组重跑(与 enqueueRecheckFailedByPReferIdWithGroup 一致)。 + * + * @param int $pArticleId + * @return array{p_article_id:int,reset:int,queued:int,check_ids:int[],transport:string,queue:string} + */ + public function recheckFailedOnlyByArticle($pArticleId) + { + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + throw new \InvalidArgumentException('p_article_id is required'); + } + + DbReconnectHelper::ensure(); + $rows = Db::name('article_reference_relevance_check_result') + ->where('p_article_id', $pArticleId) + ->where('status', self::RECORD_FAILED) + ->order('reference_no asc,am_id asc,text_start asc,id asc') + ->select(); + if (empty($rows)) { + return [ + 'p_article_id' => $pArticleId, + 'reset' => 0, + 'queued' => 0, + 'check_ids' => [], + 'transport' => self::TRANSPORT_RABBITMQ, + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + + $now = date('Y-m-d H:i:s'); + $resetFields = $this->relevanceCheckResultResetFields([ + 'updated_at' => $now, + ]); + + $targetRows = []; + foreach ($rows as $row) { + $groupRows = $this->findCitationGroupRows($row); + foreach ($groupRows as $gr) { + $checkId = $this->resolveCheckRowId($gr); + if ($checkId > 0) { + $targetRows[$checkId] = $gr; + } + } + } + + $pendingJobs = []; + foreach ($targetRows as $row) { + $checkId = $this->resolveCheckRowId($row); + Db::name('article_reference_relevance_check_result')->where('id', $checkId)->update($resetFields); + $pendingJobs[] = [ + 'check_id' => $checkId, + 'reference_no' => intval($row['reference_no']), + 'am_id' => intval($row['am_id']), + 'text_start' => intval($row['text_start']), + ]; + } + + $checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'recheck_failed'); + + return [ + 'p_article_id' => $pArticleId, + 'reset' => count($targetRows), + 'queued' => count($checkIds), + 'check_ids' => $checkIds, + 'transport' => self::TRANSPORT_RABBITMQ, + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + /** * 某条参考文献下「校对失败」的明细重新校对(异步) * diff --git a/application/common/service/ReferenceRelevanceLlmService.php b/application/common/service/ReferenceRelevanceLlmService.php index 8a07d033..c8d00878 100644 --- a/application/common/service/ReferenceRelevanceLlmService.php +++ b/application/common/service/ReferenceRelevanceLlmService.php @@ -25,13 +25,15 @@ class ReferenceRelevanceLlmService $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', '')); - $this->timeout = max(180, intval(Env::get('promotion.promotion_llm_timeout', 180))); + // 相关性校对固定至少 200s(不跟 promotion_llm_timeout=120);可用 relevance_llm_timeout 单独加大 + $timeout = intval(Env::get('promotion.relevance_llm_timeout', 0)); + $this->timeout = max(200, $timeout > 0 ? $timeout : 200); // 控制发送给 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))); $this->maxReferChars = max(1500, intval(Env::get('promotion.relevance_llm_max_refer_chars', 3500))); $this->maxAbstractChars = max(1500, intval(Env::get('promotion.relevance_llm_max_abstract_chars', 3500))); - $this->maxTokens = max(4096, intval(Env::get('promotion.relevance_llm_max_tokens', 0))); + $this->maxTokens = max(0, intval(Env::get('promotion.relevance_llm_max_tokens', 0))); } /** @@ -70,6 +72,47 @@ class ReferenceRelevanceLlmService } $refCount = $this->countCiteGroupRefs($citeGroupRefs); + // 默认每批最多 4 篇,降低单次排队/超时风险(可用 env 覆盖) + $maxRefsPerCall = max(2, intval(Env::get('promotion.relevance_llm_max_refs_per_call', 4))); + if ($refCount > $maxRefsPerCall) { + return $this->checkRelevanceByChunks( + $sectionText, + $localContext, + $referText, + $abstractText, + $citeGroupRefs, + $referTypeMap, + $refCount, + $maxRefsPerCall + ); + } + + return $this->checkRelevanceOnce( + $sectionText, + $localContext, + $referText, + $abstractText, + $citeGroupRefs, + $referTypeMap, + $refCount, + $fallback + ); + } + + /** + * @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} + */ + private function checkRelevanceOnce( + $sectionText, + $localContext, + $referText, + $abstractText, + $citeGroupRefs, + array $referTypeMap, + $refCount, + array $fallback + ) { $payload = [ 'model' => $this->model, 'temperature' => 0, @@ -135,6 +178,28 @@ class ReferenceRelevanceLlmService return array_merge($fallback, ['reason' => 'LLM returned empty or invalid results' . $detail]); } if ($refCount > 1 && count($results) < $refCount) { + if ($refCount >= 4) { + $chunkSize = max(3, intval(ceil($refCount / 2))); + $retry = $this->checkRelevanceByChunks( + $sectionText, + $localContext, + $referText, + $abstractText, + $citeGroupRefs, + $referTypeMap, + $refCount, + $chunkSize + ); + if (empty($retry['request_failed']) && count($retry['results']) >= $refCount) { + \think\Log::warning(sprintf( + 'ReferenceRelevanceLlm: recovered via split retry (%d refs, chunk=%d) cite_group_refs=%s', + $refCount, + $chunkSize, + $citeGroupRefs + )); + return $retry; + } + } $saved = $this->saveBadJsonResponse($content, [ 'cite_group_refs' => $citeGroupRefs, 'section_chars' => mb_strlen($sectionText), @@ -539,20 +604,20 @@ PROMPT; private function resolveMaxTokens($refCount) { $refCount = max(1, intval($refCount)); - // reason/combined_reason 加详后,单篇输出更长,相应上调 token 预算防截断 - $dynamic = min(16384, max(6144, $refCount * 1600)); - if ($refCount >= 6) { - $dynamic = max($dynamic, 12288); + // reason 已压缩;过高 max_tokens 会拖慢本地推理排队,按条数给够即可 + $dynamic = min(8192, max(3072, $refCount * 900 + 1200)); + if ($refCount >= 4) { + $dynamic = max($dynamic, 5120); } if ($this->maxTokens > 0) { - return max($this->maxTokens, $dynamic); + return $this->maxTokens; } return $dynamic; } /** - * 响应截断导致缺条时,用同组已有 combined_* 与中位单条分补全(最多补 2 条)。 + * 响应截断导致缺条时,用同组已有 combined_* 与中位单条分补全(非一致分时最多补 2 条;一致分时可补全)。 */ private function fillMissingGroupResults(array $out, $citeGroupRefs, $refCount, $combinedScore = 0, $combinedReason = '') { @@ -575,7 +640,11 @@ PROMPT; $missing[] = $refNo; } } - if (empty($missing) || count($missing) > 2 || empty($out)) { + if (empty($missing) || empty($out)) { + return $out; + } + $uniformScore = $this->resultsHaveUniformScore($out); + if (!$uniformScore && count($missing) > 2) { return $out; } @@ -604,6 +673,182 @@ PROMPT; return $out; } + /** + * 大引用组拆批调用 LLM,合并逐篇结果后再计算联合分。 + * + * @return array{results:array,claims?:array,combined_relevance_score?:float,combined_reason?:string,request_failed?:bool,reason?:string} + */ + private function checkRelevanceByChunks( + $sectionText, + $localContext, + $referText, + $abstractText, + $citeGroupRefs, + array $referTypeMap, + $refCount, + $chunkSize + ) { + $fallback = [ + 'results' => [], + 'request_failed' => true, + 'reason' => 'LLM split batch failed', + ]; + $refNums = $this->parseCiteGroupRefNumbers($citeGroupRefs); + if (empty($refNums)) { + return array_merge($fallback, ['reason' => 'Empty cite_group_refs']); + } + + $chunkSize = max(2, intval($chunkSize)); + $chunks = array_chunk($refNums, $chunkSize); + $allResults = []; + $claims = []; + $combinedScore = 0.0; + $combinedReason = ''; + + foreach ($chunks as $chunk) { + $chunkRefs = implode(',', $chunk); + $chunkRefer = $this->filterRefBlocks($referText, $chunk); + if ($chunkRefer === '') { + $chunkRefer = $referText; + } + $chunkAbstract = $this->filterRefBlocks($abstractText, $chunk); + $chunkTypeMap = []; + foreach ($chunk as $refNo) { + if (isset($referTypeMap[$refNo])) { + $chunkTypeMap[$refNo] = $referTypeMap[$refNo]; + } + } + + $part = $this->checkRelevanceOnce( + $sectionText, + $localContext, + $chunkRefer, + $chunkAbstract, + $chunkRefs, + $chunkTypeMap, + count($chunk), + $fallback + ); + 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]); + } + + 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'] : ''); + } + + foreach ($part['results'] as $row) { + $refNo = intval(isset($row['reference_no']) ? $row['reference_no'] : 0); + if ($refNo > 0 && !isset($allResults[$refNo])) { + $allResults[$refNo] = $row; + } + } + } + + 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 (count($results) > 1) { + $bands = $this->getScoreBands(); + $adjustedCombined = $this->enforceCombinedAgainstSingles($results, $combinedScore, $bands); + if ($combinedScore <= 0 || abs($adjustedCombined - $combinedScore) > 0.001) { + $combinedScore = $adjustedCombined; + $combinedReason = $this->fallbackReasonFromScore( + $combinedScore, + $this->levelFromScore($combinedScore) + ); + } + } elseif (count($results) === 1) { + $combinedScore = floatval($results[0]['relevance_score'] ?? 0); + $combinedReason = (string)($results[0]['reason'] ?? ''); + } + + \think\Log::info(sprintf( + 'ReferenceRelevanceLlm: split %d refs into %d batches (chunk=%d) cite_group_refs=%s', + $refCount, + count($chunks), + $chunkSize, + $citeGroupRefs + )); + + return [ + 'results' => $results, + 'claims' => $claims, + 'combined_relevance_score' => $combinedScore, + 'combined_reason' => $combinedReason, + ]; + } + + /** + * 按编号过滤「【参考文献 N】」块。 + */ + private function filterRefBlocks($text, array $refNos) + { + $text = trim((string)$text); + if ($text === '' || empty($refNos)) { + return ''; + } + $want = []; + foreach ($refNos as $refNo) { + $want[intval($refNo)] = true; + } + + $blocks = preg_split('/\n(?=【参考文献 \d+】)/u', $text); + $out = []; + $preamble = ''; + foreach ($blocks as $block) { + $block = trim($block); + if ($block === '') { + continue; + } + if (preg_match('/^【参考文献 (\d+)】/u', $block, $m)) { + $refNo = intval($m[1]); + if (!empty($want[$refNo])) { + $out[] = $block; + } + } elseif ($preamble === '') { + $preamble = $block; + } + } + + if ($preamble !== '' && !empty($out)) { + array_unshift($out, $preamble); + } + + return implode("\n\n", $out); + } + + private function resultsHaveUniformScore(array $rows) + { + $scores = []; + foreach ($rows as $row) { + if (!isset($row['relevance_score'])) { + return false; + } + $scores[] = round(floatval($row['relevance_score']), 2); + } + if (count($scores) <= 1) { + return true; + } + + return count(array_unique($scores)) === 1; + } + private function parseCiteGroupRefNumbers($citeGroupRefs) { $citeGroupRefs = trim((string)$citeGroupRefs);