refUtil = new ReferenceCheckService(); $this->logFile = ROOT_PATH . 'runtime' . DS . 'reference_relevance_check.log'; } /** * 整篇入队:扫描正文引用,写入明细并创建 RabbitMQ 文章批次 */ public function enqueueByPArticle(array $prod) { $pArticleId = intval($prod['p_article_id']); $articleId = intval($prod['article_id']); if ($pArticleId <= 0 || $articleId <= 0) { throw new \InvalidArgumentException('p_article_id and article_id required'); } DbReconnectHelper::ensure(); $referMap = $this->refUtil->loadReferMapByPArticleId($pArticleId); $mains = Db::name('article_main') ->field('am_id,content,article_id,type,amt_id') ->where('article_id', $articleId) ->whereIn('state', [0, 2]) ->order('sort asc') ->select(); if (empty($mains)) { throw new \RuntimeException('article_main is empty'); } $now = date('Y-m-d H:i:s'); $pendingJobs = []; $skipped = 0; foreach ($mains as $main) { DbReconnectHelper::release(); $citations = $this->refUtil->extractReferencesForArticleMain($main); if (empty($citations)) { continue; } foreach ($citations as $cite) { foreach ($cite['reference_numbers'] as $refNo) { $refNo = intval($refNo); $referIndex = $refNo - 1; if ($referIndex < 0 || !isset($referMap[$referIndex])) { $skipped++; continue; } DbReconnectHelper::ensure(); $checkId = $this->insertRow($prod, $main, $cite, $refNo, $referMap[$referIndex], $now); if ($checkId <= 0) { $skipped++; continue; } $pendingJobs[] = [ 'check_id' => $checkId, 'reference_no' => $refNo, 'am_id' => intval($main['am_id']), 'text_start' => intval($cite['text_start']), ]; } } } $checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'enqueue'); return [ 'p_article_id' => $pArticleId, 'article_id' => $articleId, 'queued' => count($checkIds), 'skipped' => $skipped, 'check_ids' => $checkIds, 'transport' => self::TRANSPORT_RABBITMQ, 'queue' => self::TRANSPORT_RABBITMQ, ]; } public function resetAndRecheckByArticle(array $prod) { $pArticleId = intval($prod['p_article_id']); $this->clearByPArticleId($pArticleId); $result = $this->enqueueByPArticle($prod); $result['reset'] = 1; $result['cleared'] = 1; return $result; } public function clearByPArticleId($pArticleId) { $pArticleId = intval($pArticleId); Db::name('article_reference_relevance_check_batch') ->where('p_article_id', $pArticleId) ->delete(); return Db::name('article_reference_relevance_check_result') ->where('p_article_id', $pArticleId) ->delete(); } /** * 仅重新校对 status=0 的记录,不清空历史,也不触发摘要抓取与清洗。 */ public function recheckPendingOnlyByArticle($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_PENDING) ->field('id,reference_no,am_id,text_start') ->order('reference_no asc,am_id asc,text_start asc,id asc') ->select(); if (empty($rows)) { return [ 'p_article_id' => $pArticleId, 'queued' => 0, 'check_ids' => [], 'transport' => self::TRANSPORT_RABBITMQ, 'queue' => self::TRANSPORT_RABBITMQ, ]; } $pendingJobs = []; $checkIds = []; foreach ($rows as $row) { $checkId = intval($row['id']); if ($checkId <= 0) { continue; } $checkIds[] = $checkId; $pendingJobs[] = [ 'check_id' => $checkId, 'reference_no' => intval($row['reference_no']), 'am_id' => intval($row['am_id']), 'text_start' => intval($row['text_start']), ]; } if (empty($checkIds)) { return [ 'p_article_id' => $pArticleId, 'queued' => 0, 'check_ids' => [], 'transport' => self::TRANSPORT_RABBITMQ, 'queue' => self::TRANSPORT_RABBITMQ, ]; } Db::name('article_reference_relevance_check_result') ->whereIn('id', $checkIds) ->update([ 'queue_status' => self::QUEUE_PENDING, 'retry_count' => 0, 'error_msg' => '', 'updated_at' => date('Y-m-d H:i:s'), ]); $queuedIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'recheck_pending_only'); return [ 'p_article_id' => $pArticleId, 'queued' => count($queuedIds), 'check_ids' => $queuedIds, 'transport' => self::TRANSPORT_RABBITMQ, 'queue' => self::TRANSPORT_RABBITMQ, ]; } /** * 仅重新校对某篇文章下 status=3(失败)的记录:先将 status 重置为 0,其余与 recheckPendingOnlyByArticle 相同。 * * @param int $pArticleId * @return array{p_article_id: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) ->field('id,reference_no,am_id,text_start') ->order('reference_no asc,am_id asc,text_start asc,id asc') ->select(); if (empty($rows)) { return [ 'p_article_id' => $pArticleId, 'queued' => 0, 'check_ids' => [], 'transport' => self::TRANSPORT_RABBITMQ, 'queue' => self::TRANSPORT_RABBITMQ, ]; } $pendingJobs = []; $checkIds = []; foreach ($rows as $row) { $checkId = intval($row['id']); if ($checkId <= 0) { continue; } $checkIds[] = $checkId; $pendingJobs[] = [ 'check_id' => $checkId, 'reference_no' => intval($row['reference_no']), 'am_id' => intval($row['am_id']), 'text_start' => intval($row['text_start']), ]; } if (empty($checkIds)) { return [ 'p_article_id' => $pArticleId, 'queued' => 0, 'check_ids' => [], 'transport' => self::TRANSPORT_RABBITMQ, 'queue' => self::TRANSPORT_RABBITMQ, ]; } Db::name('article_reference_relevance_check_result') ->whereIn('id', $checkIds) ->update([ 'status' => self::RECORD_PENDING, 'queue_status' => self::QUEUE_PENDING, 'retry_count' => 0, 'error_msg' => '', 'updated_at' => date('Y-m-d H:i:s'), ]); $queuedIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'recheck_failed'); return [ 'p_article_id' => $pArticleId, 'queued' => count($queuedIds), 'check_ids' => $queuedIds, 'transport' => self::TRANSPORT_RABBITMQ, 'queue' => self::TRANSPORT_RABBITMQ, ]; } /** * 某条参考文献下「校对失败」的明细重新校对(异步) * * 不刷新 refer_text / origin_text,只重置结果字段后入 RabbitMQ 批次队列。 * * @param int $pReferId * @param int $pArticleId * @return array{p_refer_id:int,p_article_id:int,reset:int,queued:int,check_ids:int[],queue:string} */ public function enqueueRecheckFailedByPReferId($pReferId, $pArticleId = 0) { $pReferId = intval($pReferId); if ($pReferId <= 0) { throw new \InvalidArgumentException('p_refer_id is required'); } DbReconnectHelper::ensure(); $q = Db::name('article_reference_relevance_check_result') ->where('p_refer_id', $pReferId) ->where('status', self::RECORD_FAILED); $pArticleId = intval($pArticleId); if ($pArticleId > 0) { $q->where('p_article_id', $pArticleId); } $rows = $q->select(); if (empty($rows)) { return [ 'p_refer_id' => $pReferId, 'p_article_id' => $pArticleId, 'reset' => 0, 'queued' => 0, 'check_ids' => [], 'queue' => self::TRANSPORT_RABBITMQ, ]; } if ($pArticleId <= 0) { $pArticleId = intval($rows[0]['p_article_id']); } $now = date('Y-m-d H:i:s'); $resetFields = $this->relevanceCheckResultResetFields([ 'updated_at' => $now, ]); $pendingJobs = []; foreach ($rows as $row) { $checkId = $this->resolveCheckRowId($row); if ($checkId <= 0) { continue; } 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_refer_id' => $pReferId, 'p_article_id' => $pArticleId, 'reset' => count($rows), 'queued' => count($checkIds), 'check_ids' => $checkIds, 'queue' => self::TRANSPORT_RABBITMQ, ]; } /** * 失败重跑:扩展到同一引用标签分组(如 [1,2])全部重跑。 */ public function enqueueRecheckFailedByPReferIdWithGroup($pReferId, $pArticleId = 0) { $pReferId = intval($pReferId); if ($pReferId <= 0) { throw new \InvalidArgumentException('p_refer_id is required'); } DbReconnectHelper::ensure(); $q = Db::name('article_reference_relevance_check_result') ->where('p_refer_id', $pReferId) ->where('status', self::RECORD_FAILED); $pArticleId = intval($pArticleId); if ($pArticleId > 0) { $q->where('p_article_id', $pArticleId); } $rows = $q->select(); if (empty($rows)) { return [ 'p_refer_id' => $pReferId, 'p_article_id' => $pArticleId, 'reset' => 0, 'queued' => 0, 'check_ids' => [], 'queue' => self::TRANSPORT_RABBITMQ, ]; } if ($pArticleId <= 0) { $pArticleId = intval($rows[0]['p_article_id']); } $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_refer_id' => $pReferId, 'p_article_id' => $pArticleId, 'reset' => count($targetRows), 'queued' => count($checkIds), 'check_ids' => $checkIds, 'queue' => self::TRANSPORT_RABBITMQ, ]; } /** * 执行单条相关性校对 */ public function runCheckOnce($checkId, $skipLiteratureFetch = false) { DbReconnectHelper::ensure(); $checkId = intval($checkId); $row = Db::name('article_reference_relevance_check_result')->where('id', $checkId)->find(); if (empty($row)) { throw new \RuntimeException('relevance check row not found, id=' . $checkId); } if (intval($row['status']) === self::RECORD_COMPLETED) { return $this->formatReturnFromRow($row); } $groupRows = $this->findCitationGroupRows($row); if ($this->isCitationGroupCheck($groupRows)) { $leaderRefNo = $this->resolveGroupLeaderRefNo($groupRows); $currentRefNo = intval($row['reference_no']); if ($currentRefNo !== $leaderRefNo) { DbReconnectHelper::ensure(); $fresh = Db::name('article_reference_relevance_check_result')->where('id', $checkId)->find(); if (!empty($fresh) && intval($fresh['status']) === self::RECORD_COMPLETED) { return $this->formatReturnFromRow($fresh); } throw new \RuntimeException('Citation group leader not finished, reference_no=' . $leaderRefNo); } } 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); if ($skipLiteratureFetch) { $literatureBundle = $this->resolveGroupLiteratureBundle($groupRows, $referTypeMap, false); $abstractText = $literatureBundle['combined_text']; } else { // 优先读 t_production_article_refer;摘要与清洗内容都为空时再抓取并回写 refer 表 DbReconnectHelper::release(); $literatureBundle = $this->resolveGroupLiteratureBundle($groupRows, $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); throw new \RuntimeException($msg); } DbReconnectHelper::release(); $llm = (new ReferenceRelevanceLlmService())->checkRelevance( $sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $referTypeMap ); DbReconnectHelper::ensure(); if (!empty($llm['request_failed']) || !$this->applyGroupResults($groupRows, $llm)) { $msg = isset($llm['reason']) ? (string)$llm['reason'] : 'LLM failed or empty results'; $this->failGroupWithQueue($groupRows, $msg); throw new \RuntimeException($msg); } if ($noLiteratureEvidence) { $this->markGroupNoLiteratureEvidence($groupRows); } $this->markGroupQueueRuntime($groupRows, self::QUEUE_COMPLETED); $fresh = Db::name('article_reference_relevance_check_result')->where('id', $checkId)->find(); return $this->formatReturnFromRow(!empty($fresh) ? $fresh : $row); } public function getProgressByPArticleId($pArticleId) { $pArticleId = intval($pArticleId); if ($pArticleId <= 0) { throw new \InvalidArgumentException('p_article_id is required'); } $rows = Db::name('article_reference_relevance_check_result') ->where('p_article_id', $pArticleId) ->order('reference_no asc, id asc') ->select(); $summary = ['pending' => 0, 'checking' => 0, 'completed' => 0, 'failed' => 0]; if (empty($rows)) { return [ 'p_article_id' => $pArticleId, 'total_groups' => 0, 'summary' => $summary, 'list' => [], ]; } $groups = []; foreach ($rows as $row) { $refNo = intval($row['reference_no']); $pReferId = intval($row['p_refer_id']); if (!isset($groups[$refNo])) { $groups[$refNo] = [ 'reference_no' => $refNo, 'p_refer_id' => $pReferId, 'total' => 0, 'pending' => 0, 'done' => 0, 'failed' => 0, 'pass' => 0, 'last_updated_at' => '', 'records' => [], ]; } if ($groups[$refNo]['p_refer_id'] <= 0 && $pReferId > 0) { $groups[$refNo]['p_refer_id'] = $pReferId; } $g = &$groups[$refNo]; $g['total']++; $st = intval($row['status']); if ($st === self::RECORD_PENDING) { $g['pending']++; } elseif ($st === self::RECORD_COMPLETED) { $g['done']++; } elseif ($st === self::RECORD_FAILED) { $g['failed']++; } $upd = (string)(isset($row['updated_at']) ? $row['updated_at'] : ''); if ($upd > $g['last_updated_at']) { $g['last_updated_at'] = $upd; } $score = floatval($row['relevance_score']); $isPass = $score >= self::PASS_SCORE_THRESHOLD; if ($isPass) { $g['pass']++; } $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; $g['records'][] = [ 'check_id' => intval($row['id']), 'reference_no' => intval($row['reference_no']), 'am_id' => intval($row['am_id']), 'status' => $st, 'is_relevant' => intval($row['is_relevant']), 'relevance_score' => $score, 'is_pass' => $isPass, 'reason' => (string)$row['reason'], 'author_comment' => $author_comment, 'combined_relevance_score' => floatval($row['combined_relevance_score']), 'combined_reason' => (string)$row['combined_reason'], 'cite_group_refs' => (string)$row['cite_group_refs'], 'claims' => $claims, 'evidence_mode' => ((string)($row['score_ceiling_trigger'] ?? '') === 'no_literature_evidence') ? 'bibliographic_only' : 'literature_evidence', 'has_literature_evidence' => ((string)($row['score_ceiling_trigger'] ?? '') !== 'no_literature_evidence'), 'cite_check_mode' => strpos((string)$row['cite_group_refs'], ',') !== false ? 'joint' : 'single', 'origin_text' => (string)$row['origin_text'], 'last_updated_at' => $upd, ]; unset($g); } $list = []; foreach ($groups as $g) { $total = $g['total']; $pending = $g['pending']; $failed = $g['failed']; $pass = $g['pass']; if ($pending === $total) { $ps = 0; } elseif ($pending === 0) { $ps = $failed > 0 ? 3 : 2; } else { $ps = 1; } $g['progress_status'] = $ps; $g['is_pass'] = ($ps === 2 && $pass === $total && $total > 0); switch ($ps) { case 0: $summary['pending']++; break; case 1: $summary['checking']++; break; case 2: $summary['completed']++; break; case 3: $summary['failed']++; break; } $list[] = $g; } usort($list, function ($a, $b) { return $a['reference_no'] - $b['reference_no']; }); return [ 'p_article_id' => $pArticleId, 'total_groups' => count($list), 'summary' => $summary, 'list' => $list, ]; } /** * 按 p_article_id 查整篇文章的相关性校对总状态(按 reference_no 分组统计) * * @return array{p_article_id:int, status:int, total:int, pending:int, done:int, failed:int, progress_percent:float} */ public function getArticleProgressStatusByPArticleId($pArticleId) { $pArticleId = intval($pArticleId); if ($pArticleId <= 0) { throw new \InvalidArgumentException('p_article_id is required'); } $rows = Db::name('article_reference_relevance_check_result') ->field('reference_no' . ', SUM(CASE WHEN status = ' . self::RECORD_PENDING . ' THEN 1 ELSE 0 END) AS pending_cnt' . ', SUM(CASE WHEN status = ' . self::RECORD_FAILED . ' THEN 1 ELSE 0 END) AS failed_cnt') ->where('p_article_id', $pArticleId) ->group('reference_no') ->select(); if (empty($rows)) { return [ 'p_article_id' => $pArticleId, 'status' => self::ARTICLE_PROGRESS_NONE, 'total' => 0, 'pending' => 0, 'done' => 0, 'failed' => 0, 'progress_percent' => 0, ]; } $pending = 0; $done = 0; $failed = 0; foreach ($rows as $row) { $pendingCnt = intval(isset($row['pending_cnt']) ? $row['pending_cnt'] : 0); $failedCnt = intval(isset($row['failed_cnt']) ? $row['failed_cnt'] : 0); if ($pendingCnt > 0) { $pending++; } elseif ($failedCnt > 0) { $failed++; } else { $done++; } } $total = count($rows); $articleStatus = $pending > 0 ? self::ARTICLE_PROGRESS_RUNNING : self::ARTICLE_PROGRESS_COMPLETED; $finished = $done + $failed; $progressPercent = round($finished / $total * 100, 1); return [ 'p_article_id' => $pArticleId, 'status' => $articleStatus, 'total' => $total, 'pending' => $pending, 'done' => $done, 'failed' => $failed, 'progress_percent' => $progressPercent, ]; } /** * 按 p_refer_id 查单条参考文献的相关性校对明细与分组进度 * * @return array{p_refer_id:int,p_article_id:int,reference_no:int,total:int,pending:int,done:int,failed:int,pass:int,progress_status:int,progress_percent:float,is_pass:bool,last_updated_at:string,list:array} */ public function getDetailsByPReferId($pReferId) { $pReferId = intval($pReferId); if ($pReferId <= 0) { throw new \InvalidArgumentException('p_refer_id is required'); } $rows = Db::name('article_reference_relevance_check_result') ->where('p_refer_id', $pReferId) ->order('id asc') ->select(); $list = []; $pArticleId = 0; $referenceNo = 0; $pending = 0; $done = 0; $failed = 0; $pass = 0; $lastUpdatedAt = ''; foreach ($rows as $row) { if ($pArticleId <= 0) { $pArticleId = intval($row['p_article_id']); } if ($referenceNo <= 0) { $referenceNo = intval($row['reference_no']); } $st = intval($row['status']); if ($st === self::RECORD_PENDING) { $pending++; } elseif ($st === self::RECORD_COMPLETED) { $done++; } elseif ($st === self::RECORD_FAILED) { $failed++; } $upd = (string)(isset($row['updated_at']) ? $row['updated_at'] : ''); if ($upd > $lastUpdatedAt) { $lastUpdatedAt = $upd; } $score = floatval($row['relevance_score']); $isPass = $score >= self::PASS_SCORE_THRESHOLD; if ($isPass) { $pass++; } $item = $this->formatReturnFromRow($row); $item['cite_check_mode'] = strpos((string)$row['cite_group_refs'], ',') !== false ? 'joint' : 'single'; $item['is_pass'] = $isPass; $list[] = $item; } if ($referenceNo <= 0) { $refer = Db::name('production_article_refer') ->where('p_refer_id', $pReferId) ->where('state', 0) ->find(); if (!empty($refer)) { if ($pArticleId <= 0) { $pArticleId = intval($refer['p_article_id']); } $referenceNo = intval($refer['index']) + 1; } } $total = count($list); if ($total === 0) { $progressStatus = 0; $progressPercent = 0; $isPassGroup = false; } elseif ($pending === $total) { $progressStatus = 0; $progressPercent = 0; $isPassGroup = false; } elseif ($pending === 0) { $progressStatus = $failed > 0 ? 3 : 2; $progressPercent = 100; $isPassGroup = ($progressStatus === 2 && $pass === $total); } else { $progressStatus = 1; $finished = $done + $failed; $progressPercent = round($finished / $total * 100, 1); $isPassGroup = false; } return [ 'p_refer_id' => $pReferId, 'p_article_id' => $pArticleId, 'reference_no' => $referenceNo, 'total' => $total, 'pending' => $pending, 'done' => $done, 'failed' => $failed, 'pass' => $pass, 'progress_status' => $progressStatus, 'progress_percent' => $progressPercent, 'is_pass' => $isPassGroup, 'last_updated_at' => $lastUpdatedAt, 'list' => $list, ]; } public function markQueueRuntime($checkId, $queueStatus, $retryCount = null) { DbReconnectHelper::ensure(); $fields = [ 'queue_status' => intval($queueStatus), 'updated_at' => date('Y-m-d H:i:s'), ]; if ($retryCount !== null) { $fields['retry_count'] = max(0, intval($retryCount)); } return Db::name('article_reference_relevance_check_result') ->where('id', intval($checkId)) ->update($fields); } /** * 修复卡住队列:已完成但 queue 未同步;长时间 RUNNING 回退为待执行 */ /** * @param bool $forceRunning 为 true 时立即回收本篇仍为 RUNNING 的 pending 行(断点续跑用),不再等 10 分钟 */ public function recoverQueueRowsForArticle($pArticleId, $forceRunning = false) { $pArticleId = intval($pArticleId); if ($pArticleId <= 0) { return; } DbReconnectHelper::ensure(); $now = date('Y-m-d H:i:s'); Db::name('article_reference_relevance_check_result') ->where('p_article_id', $pArticleId) ->where('status', self::RECORD_COMPLETED) ->where('queue_status', '<>', self::QUEUE_COMPLETED) ->update([ 'queue_status' => self::QUEUE_COMPLETED, 'updated_at' => $now, ]); // 默认仅回收“长时间未更新”的 RUNNING;force 时立刻回退,从卡死条继续 $runningQuery = Db::name('article_reference_relevance_check_result') ->where('p_article_id', $pArticleId) ->where('queue_status', self::QUEUE_RUNNING) ->where('status', self::RECORD_PENDING); if (!$forceRunning) { $runningStaleBefore = date('Y-m-d H:i:s', time() - 600); $runningQuery->where('updated_at', '<', $runningStaleBefore); } $runningQuery->update([ 'queue_status' => self::QUEUE_PENDING, 'updated_at' => $now, ]); $staleBefore = date('Y-m-d H:i:s', time() - 600); Db::name('article_reference_relevance_check_result') ->where('p_article_id', $pArticleId) ->where('queue_status', self::QUEUE_RUNNING) ->where('status', self::RECORD_FAILED) ->where('updated_at', '<', $staleBefore) ->update([ 'queue_status' => self::QUEUE_FAILED, 'updated_at' => $now, ]); } /** * 校对前预处理:按 DOI 多源抓取(Europe PMC → PubMed → PMC → Unpaywall PDF → Crossref; * cma.j.cn 走 OpenAlex,暂不调用 Yiigle 机构 API),清洗后写入 t_production_article_refer_literature。 * * @return array{ * p_article_id:int, * total:int, * cached:int, * fetched:int, * fallback:int, * empty:int, * items:array * } */ public function prepareLiteratureContentByArticle($pArticleId) { $pArticleId = intval($pArticleId); $summary = [ 'p_article_id' => $pArticleId, 'total' => 0, 'cached' => 0, 'fetched' => 0, 'fallback' => 0, 'empty' => 0, 'items' => [], ]; if ($pArticleId <= 0) { return $summary; } DbReconnectHelper::ensure(); $rows = Db::name('article_reference_relevance_check_result') ->where('p_article_id', $pArticleId) ->field('p_refer_id,reference_no') ->select(); if (empty($rows)) { return $summary; } $needReferIds = []; foreach ($rows as $row) { $pReferId = intval($row['p_refer_id'] ?? 0); if ($pReferId <= 0) { continue; } if (!isset($needReferIds[$pReferId])) { $needReferIds[$pReferId] = intval($row['reference_no'] ?? 0); } } if (empty($needReferIds)) { return $summary; } $this->log('prepare literature start p_article_id=' . $pArticleId . ' refer_count=' . count($needReferIds)); foreach ($needReferIds as $pReferId => $referenceNo) { $summary['total']++; $item = $this->prepareLiteratureContentForRefer($pArticleId, intval($pReferId), intval($referenceNo)); $summary['items'][] = $item; $status = (string)($item['status'] ?? 'empty'); if (isset($summary[$status])) { $summary[$status]++; } else { $summary['empty']++; } } $this->log( 'prepare literature done p_article_id=' . $pArticleId . ' total=' . $summary['total'] . ' fetched=' . $summary['fetched'] . ' cached=' . $summary['cached'] . ' fallback=' . $summary['fallback'] . ' empty=' . $summary['empty'] ); return $summary; } /** * 从相关性校对明细按 p_refer_id 去重,抓取 API 原文写入 t_production_article_refer_literature(不 LLM 清洗)。 * * @return array{p_article_id:int,total:int,cached:int,fetched:int,empty:int,items:array} */ public function fetchReferLiteratureByRelevanceArticle($pArticleId, $forceRefetch = false) { $pArticleId = intval($pArticleId); $summary = [ 'p_article_id' => $pArticleId, 'total' => 0, 'cached' => 0, 'fetched' => 0, 'empty' => 0, 'items' => [], ]; if ($pArticleId <= 0) { return $summary; } DbReconnectHelper::ensure(); $rows = Db::name('article_reference_relevance_check_result') ->where('p_article_id', $pArticleId) ->field('p_refer_id,reference_no') ->select(); if (empty($rows)) { return $summary; } $needReferIds = []; foreach ($rows as $row) { $pReferId = intval($row['p_refer_id'] ?? 0); if ($pReferId <= 0) { continue; } if (!isset($needReferIds[$pReferId])) { $needReferIds[$pReferId] = intval($row['reference_no'] ?? 0); } } if (empty($needReferIds)) { return $summary; } $this->log('fetch refer literature(raw) start p_article_id=' . $pArticleId . ' refer_count=' . count($needReferIds)); foreach ($needReferIds as $pReferId => $referenceNo) { $summary['total']++; $item = $this->fetchReferLiteratureRawForRefer( $pArticleId, intval($pReferId), intval($referenceNo), (bool)$forceRefetch ); $summary['items'][] = $item; $status = (string)($item['status'] ?? 'empty'); if (isset($summary[$status])) { $summary[$status]++; } else { $summary['empty']++; } } $this->log( 'fetch refer literature(raw) done p_article_id=' . $pArticleId . ' total=' . $summary['total'] . ' fetched=' . $summary['fetched'] . ' cached=' . $summary['cached'] . ' empty=' . $summary['empty'] ); return $summary; } /** * 单条参考文献:仅 API 抓取,写入 t_production_article_refer_literature(不清洗、不改主表)。 */ public function fetchReferLiteratureRawForRefer($pArticleId, $pReferId, $referenceNo = 0, $forceRefetch = false) { $pArticleId = intval($pArticleId); $pReferId = intval($pReferId); $referenceNo = intval($referenceNo); $item = [ 'p_refer_id' => $pReferId, 'reference_no' => $referenceNo, 'status' => 'empty', 'sources' => [], 'fetch_log' => '', 'abstract_len' => 0, 'content_len' => 0, 'mesh_len' => 0, 'literature_pdf_url' => '', ]; if ($pReferId <= 0) { return $item; } $litSvc = new ProductionArticleReferLiteratureService(); if (!$forceRefetch) { $stored = $litSvc->getByPReferId($pReferId, $pArticleId); if (!empty($stored) && $this->storedLiteratureRowHasFetchedContent($stored)) { $item['status'] = 'cached'; $item['abstract_len'] = mb_strlen((string)($stored['abstract_text'] ?? '')); $item['content_len'] = mb_strlen((string)($stored['content_text'] ?? '')); $item['mesh_len'] = mb_strlen((string)($stored['mesh_terms'] ?? '')); $item['literature_pdf_url'] = trim((string)($stored['literature_pdf_url'] ?? '')); $item['fetch_log'] = 'literature_table_cache'; $item['sources'] = array_values(array_filter(explode(',', (string)($stored['fetch_sources'] ?? '')))); return $item; } } DbReconnectHelper::ensure(); $q = Db::name('production_article_refer')->where('p_refer_id', $pReferId); if ($pArticleId > 0) { $q->where('p_article_id', $pArticleId); } $refer = $q->find(); if (empty($refer)) { $item['fetch_log'] = 'refer_not_found'; return $item; } DbReconnectHelper::release(); $bundle = (new ReferenceLiteratureFetchService())->setSkipYiigle(true)->fetchForRefer($refer); DbReconnectHelper::ensure(); $abstract = trim((string)($bundle['abstract'] ?? '')); $content = trim((string)($bundle['raw_content'] ?? '')); if ($content === '' && $abstract !== '') { $content = $abstract; } $pdfUrl = trim((string)($bundle['pdf_url'] ?? '')); $mesh = $litSvc->formatMeshTerms($bundle['mesh_terms'] ?? []); $item['sources'] = array_values((array)($bundle['sources'] ?? [])); $item['fetch_log'] = trim((string)($bundle['fetch_log'] ?? '')); $item['literature_pdf_url'] = $pdfUrl; if ($abstract === '' && $content === '' && $mesh === '' && $pdfUrl === '') { return $item; } $referDoi = trim((string)($refer['refer_doi'] ?? '')); if ($referDoi === '') { $referDoi = trim((string)($refer['doilink'] ?? '')); } if ($referDoi === '') { $referDoi = trim((string)($bundle['doi'] ?? '')); } $litSvc->upsert($pArticleId, $pReferId, [ 'refer_doi' => $referDoi, 'abstract_text' => $abstract, 'content_text' => $content, 'mesh_terms' => $mesh, 'refer_content_cleaned' => '', 'literature_pdf_url' => $pdfUrl, 'fetch_sources' => $bundle['sources'] ?? [], 'fetch_log' => $bundle['fetch_log'] ?? '', ]); $item['status'] = 'fetched'; $item['abstract_len'] = mb_strlen($abstract); $item['content_len'] = mb_strlen($content); $item['mesh_len'] = mb_strlen($mesh); return $item; } private function storedLiteratureRowHasFetchedContent(array $stored) { if (trim((string)($stored['content_text'] ?? '')) !== '') { return true; } if (trim((string)($stored['abstract_text'] ?? '')) !== '') { return true; } if (trim((string)($stored['mesh_terms'] ?? '')) !== '') { return true; } return trim((string)($stored['literature_pdf_url'] ?? '')) !== ''; } /** * 单条参考文献:DOI 多源抓取 + LLM 清洗,写入 t_production_article_refer_literature。 * * @return array{ * p_refer_id:int, * reference_no:int, * status:string, * sources:array, * fetch_log:string, * abstract_len:int, * cleaned_len:int, * literature_pdf_url:string, * has_verifiable_fetch:bool * } */ public function prepareLiteratureContentForRefer($pArticleId, $pReferId, $referenceNo = 0) { $pArticleId = intval($pArticleId); $pReferId = intval($pReferId); $referenceNo = intval($referenceNo); $item = [ 'p_refer_id' => $pReferId, 'reference_no' => $referenceNo, 'status' => 'empty', 'sources' => [], 'fetch_log' => '', 'abstract_len' => 0, 'cleaned_len' => 0, 'literature_pdf_url' => '', 'has_verifiable_fetch' => false, ]; if ($pReferId <= 0) { return $item; } DbReconnectHelper::ensure(); $q = Db::name('production_article_refer')->where('p_refer_id', $pReferId); if ($pArticleId > 0) { $q->where('p_article_id', $pArticleId); } $refer = $q->find(); if (empty($refer)) { $item['fetch_log'] = 'refer_not_found'; return $item; } $litSvc = new ProductionArticleReferLiteratureService(); $lit = $litSvc->loadForCheck($pReferId, $pArticleId); $abstract = trim((string)($lit['abstract_text'] ?? '')); $cleaned = trim((string)($lit['refer_content_cleaned'] ?? '')); $contentText = trim((string)($lit['content_text'] ?? '')); $fetchSvc = new ReferenceLiteratureFetchService(); if ($abstract !== '' || $cleaned !== '' || $contentText !== '') { // 规则统一:同一 p_refer_id 只要子表已有内容即视为缓存命中,不再触发外部抓取 $substantive = $this->isSubstantiveLiteratureContent($abstract) || $this->isSubstantiveLiteratureContent($cleaned) || ($cleaned === '' && $this->isSubstantiveLiteratureContent($contentText)); $item['status'] = 'cached'; $item['abstract_len'] = mb_strlen($abstract); $item['cleaned_len'] = mb_strlen($cleaned !== '' ? $cleaned : $contentText); $item['literature_pdf_url'] = trim((string)($lit['literature_pdf_url'] ?? '')); $item['has_verifiable_fetch'] = $substantive; $item['fetch_log'] = 'literature_table_cache'; return $item; } DbReconnectHelper::release(); $bundle = $fetchSvc->setSkipYiigle(true)->fetchAndCleanForRefer($refer); DbReconnectHelper::ensure(); $abstract = trim((string)($bundle['abstract_final'] ?? '')); $raw = trim((string)($bundle['raw_content'] ?? '')); $cleaned = trim((string)($bundle['content_cleaned'] ?? '')); if ($cleaned === '' && $raw !== '') { $cleaned = mb_substr($raw, 0, 6000); } $item['sources'] = array_values((array)($bundle['sources'] ?? [])); $item['fetch_log'] = trim((string)($bundle['fetch_log'] ?? '')); $pdfUrl = trim((string)($bundle['pdf_url'] ?? '')); $hasVerifiableFetch = $this->hasVerifiableFetchFromBundle($bundle); if ($abstract === '' && $cleaned === '') { $fallback = $this->buildReferBibliographicFallback($refer, $this->refUtil->formatReferForLlm($refer)); if ($fallback !== '') { $cleaned = $fallback; $hasVerifiableFetch = false; $this->persistProductionReferLiterature($pArticleId, $pReferId, '', $cleaned, $pdfUrl, $bundle); $item['status'] = 'fallback'; $item['cleaned_len'] = mb_strlen($cleaned); $item['literature_pdf_url'] = $pdfUrl; $item['has_verifiable_fetch'] = false; return $item; } $item['status'] = 'empty'; $item['literature_pdf_url'] = $pdfUrl; return $item; } $this->persistProductionReferLiterature($pArticleId, $pReferId, $abstract, $cleaned, $pdfUrl, $bundle); $item['status'] = 'fetched'; $item['abstract_len'] = mb_strlen($abstract); $item['cleaned_len'] = mb_strlen($cleaned); $item['literature_pdf_url'] = $pdfUrl; $item['has_verifiable_fetch'] = $hasVerifiableFetch; return $item; } public function markGroupQueueRuntime(array $groupRows, $queueStatus, $retryCount = null) { foreach ($groupRows as $gr) { $checkId = intval(isset($gr['id']) ? $gr['id'] : 0); if ($checkId > 0) { $this->markQueueRuntime($checkId, $queueStatus, $retryCount); } } } public function failGroupWithQueue(array $groupRows, $msg, $retryCount = null) { $this->failGroup($groupRows, $msg); $this->markGroupQueueRuntime($groupRows, self::QUEUE_FAILED, $retryCount); } public function resolveCheckRowId($row) { if (!is_array($row)) { return 0; } return intval(isset($row['id']) ? $row['id'] : 0); } public function updateCheckResult($checkId, array $fields) { return $this->updateRow(intval($checkId), $fields); } /** * @param array $rows 元素含 check_id * @param int $pArticleId * @param string $trigger * @return int[] */ public function enqueueChecksSortedByReferenceNo(array $rows, $pArticleId = 0, $trigger = 'enqueue') { usort($rows, function ($a, $b) { if ($a['reference_no'] !== $b['reference_no']) { return $a['reference_no'] - $b['reference_no']; } if ($a['am_id'] !== $b['am_id']) { return $a['am_id'] - $b['am_id']; } return $a['text_start'] - $b['text_start']; }); $checkIds = []; foreach ($rows as $row) { $checkId = intval($row['check_id']); if ($checkId > 0) { $checkIds[] = $checkId; } } if (!empty($checkIds)) { $this->startArticleRelevanceQueue($checkIds, intval($pArticleId), $trigger); } return $checkIds; } /** * 创建文章批次;队首批次立即发 MQ,其余批次链式等待前序完成 * * @param int[] $checkIds * @param int $pArticleId * @param string $trigger * @return int[] */ public function startArticleRelevanceQueue(array $checkIds, $pArticleId = 0, $trigger = 'enqueue') { $checkIds = array_values(array_filter(array_map('intval', $checkIds))); if (empty($checkIds)) { return []; } $pArticleId = intval($pArticleId); if ($pArticleId <= 0) { $firstRow = Db::name('article_reference_relevance_check_result')->where('id', $checkIds[0])->find(); $pArticleId = empty($firstRow) ? 0 : intval($firstRow['p_article_id']); } if ($pArticleId <= 0) { throw new \RuntimeException('p_article_id is required for relevance check queue'); } $now = date('Y-m-d H:i:s'); DbReconnectHelper::ensure(); $batchId = Db::name('article_reference_relevance_check_batch')->insertGetId([ 'p_article_id' => $pArticleId, 'batch_status' => 0, 'total_count' => count($checkIds), 'done_count' => 0, 'failed_count' => 0, 'trigger' => (string)$trigger, 'created_at' => $now, 'updated_at' => $now, ]); $shouldPublish = !$this->hasEarlierWaitingBatch($batchId) && !$this->hasRunningRelevanceBatch(); if ($shouldPublish) { DbReconnectHelper::release(); (new ReferenceCheckMqPublisher())->publishArticleStart($pArticleId, intval($batchId), $trigger); DbReconnectHelper::ensure(); $this->log('startArticleRelevanceQueue publish p_article_id=' . $pArticleId . ' batch_id=' . $batchId); } else { $this->log('startArticleRelevanceQueue queued batch_id=' . $batchId . ' p_article_id=' . $pArticleId); } return $checkIds; } private function hasRunningRelevanceBatch() { return Db::name('article_reference_relevance_check_batch') ->where('batch_status', 1) ->count() > 0; } private function hasEarlierWaitingBatch($batchId) { return Db::name('article_reference_relevance_check_batch') ->where('batch_status', 0) ->where('id', '<', intval($batchId)) ->count() > 0; } /** * 多篇文章并行校对时,查询指定文章前面还有几篇在排队。 */ public function getArticleCheckQueuePositionByPArticleId($pArticleId) { $pArticleId = intval($pArticleId); if ($pArticleId <= 0) { throw new \InvalidArgumentException('p_article_id is required'); } $rows = Db::name('article_reference_relevance_check_result') ->field('p_article_id, MIN(id) AS queue_anchor') ->where('status', self::RECORD_PENDING) ->group('p_article_id') ->order('queue_anchor', 'asc') ->select(); $runningIds = []; foreach ($rows as $row) { $aid = intval(isset($row['p_article_id']) ? $row['p_article_id'] : 0); if ($aid > 0) { $runningIds[] = $aid; } } $runningTotal = count($runningIds); $ahead = 0; $position = 0; $inQueue = false; foreach ($runningIds as $idx => $aid) { if ($aid === $pArticleId) { $ahead = $idx; $position = $idx + 1; $inQueue = true; break; } } $articleStatus = $this->getArticleProgressStatusByPArticleId($pArticleId); return [ 'p_article_id' => $pArticleId, 'running_total' => $runningTotal, 'ahead' => $inQueue ? $ahead : 0, 'position' => $inQueue ? $position : 0, 'in_queue' => $inQueue, 'status' => intval(isset($articleStatus['status']) ? $articleStatus['status'] : self::ARTICLE_PROGRESS_NONE), ]; } public function log($msg) { $line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL; @file_put_contents($this->logFile, $line, FILE_APPEND); } private function insertRow(array $prod, array $main, array $cite, $refNo, array $refer, $now) { $meta = $this->citationMeta($cite); $originText = trim((string)$meta['origin_text']); return intval(Db::name('article_reference_relevance_check_result')->insertGetId([ 'article_id' => intval($prod['article_id']), 'p_article_id' => intval($prod['p_article_id']), 'am_id' => intval($main['am_id']), 'p_refer_id' => intval($refer['p_refer_id']), 'reference_no' => intval($refNo), 'cite_group_refs' => $meta['cite_group_refs'], 'cite_tag_start' => $meta['cite_tag_start'], 'cite_tag_end' => $meta['cite_tag_end'], 'text_start' => $meta['text_start'], 'text_end' => $meta['text_end'], 'origin_text' => $originText, 'refer_text' => $this->refUtil->formatReferForLlm($refer), 'abstract_text' => '', 'refer_content_cleaned' => '', 'author_comment' => '', 'status' => self::RECORD_PENDING, 'queue_status' => self::QUEUE_PENDING, 'retry_count' => 0, 'created_at' => $now, 'updated_at' => $now, ])); } private function citationMeta(array $cite) { $nums = []; foreach ((array)$cite['reference_numbers'] as $n) { $n = intval($n); if ($n > 0) { $nums[$n] = $n; } } $list = array_values($nums); sort($list, SORT_NUMERIC); return [ 'cite_group_refs' => implode(',', $list), 'cite_tag_start' => intval($cite['reference_start']), 'cite_tag_end' => intval($cite['reference_end']), 'origin_text' => (string)$cite['original_text'], 'text_start' => intval($cite['text_start']), 'text_end' => intval($cite['text_end']), ]; } public function findCitationGroupRowsForWorker(array $row) { return $this->findCitationGroupRows($row); } private function findCitationGroupRows(array $row) { $amId = intval($row['am_id']); if ($amId <= 0) { return [$row]; } $q = Db::name('article_reference_relevance_check_result')->where('am_id', $amId); $citeTagStart = intval($row['cite_tag_start']); $citeTagEnd = intval($row['cite_tag_end']); if ($citeTagStart > 0 && $citeTagEnd > $citeTagStart) { $q->where('cite_tag_start', $citeTagStart)->where('cite_tag_end', $citeTagEnd); } else { $q->where('text_start', intval($row['text_start'])) ->where('text_end', intval($row['text_end'])) ->where('cite_group_refs', (string)$row['cite_group_refs']); } $rows = $q->order('reference_no asc')->select(); return empty($rows) ? [$row] : $rows; } private function isCitationGroupCheck(array $groupRows) { return count($groupRows) > 1; } private function resolveGroupLeaderRefNo(array $groupRows) { $leader = PHP_INT_MAX; foreach ($groupRows as $gr) { $refNo = intval($gr['reference_no']); if ($refNo > 0 && $refNo < $leader) { $leader = $refNo; } } return $leader === PHP_INT_MAX ? 0 : $leader; } private function resolveCiteGroupRefs(array $row, array $groupRows) { $refs = trim((string)$row['cite_group_refs']); if ($refs !== '') { return $refs; } $nums = []; foreach ($groupRows as $gr) { $n = intval($gr['reference_no']); if ($n > 0) { $nums[$n] = $n; } } $list = array_values($nums); sort($list, SORT_NUMERIC); return implode(',', $list); } private function buildCombinedLiteratureText(array $groupRows) { return $this->resolveGroupLiteratureBundle($groupRows, [], true)['combined_text']; } /** * 抓取/读取组内各文献内容,并判断是否存在可校对的外部证据(摘要/全文)。 * * @return array{combined_text:string,has_verification_evidence:bool} */ private function resolveGroupLiteratureBundle(array $groupRows, array $referTypeMap = [], $fetchIfMissing = true) { $blocks = []; $hasEvidence = false; foreach ($groupRows as $gr) { $refNo = intval($gr['reference_no']); if ($refNo <= 0) { continue; } $referType = 'journal'; if (!empty($referTypeMap[$refNo]['type'])) { $referType = (string)$referTypeMap[$refNo]['type']; } $lit = $this->resolveLiteratureForGroupRow($gr, $fetchIfMissing); if ($this->literatureHasVerificationEvidence($lit, $referType)) { $hasEvidence = true; } $text = $this->formatLiteratureBlockForLlm($lit['abstract'], $lit['cleaned']); if ($text !== '') { $blocks[] = '【参考文献 ' . $refNo . "】\n" . $text; } } return [ 'combined_text' => implode("\n\n", $blocks), 'has_verification_evidence' => $hasEvidence, ]; } /** * @return array{abstract:string,cleaned:string,from_bibliographic_fallback:bool,has_verifiable_fetch:bool} */ private function resolveLiteratureForGroupRow(array $gr, $fetchIfMissing = true) { $pArticleId = intval($gr['p_article_id'] ?? 0); $pReferId = intval($gr['p_refer_id'] ?? 0); if ($pReferId <= 0) { return [ 'abstract' => '', 'cleaned' => '', 'from_bibliographic_fallback' => false, 'has_verifiable_fetch' => false, ]; } $lit = $this->ensureProductionReferLiterature( $pArticleId, $pReferId, $fetchIfMissing, trim((string)($gr['refer_text'] ?? '')) ); $checkId = intval($gr['id'] ?? 0); if ($checkId > 0 && ($lit['abstract'] !== '' || $lit['cleaned'] !== '')) { $this->updateRow($checkId, [ 'abstract_text' => $lit['abstract'], 'refer_content_cleaned' => $lit['cleaned'], ]); } return $lit; } private function literatureHasVerificationEvidence(array $lit, $referType = 'journal') { $abstract = trim((string)($lit['abstract'] ?? '')); $cleaned = trim((string)($lit['cleaned'] ?? '')); if ($abstract !== '' && $this->isSubstantiveLiteratureContent($abstract)) { return true; } if ($referType === 'book') { return $cleaned !== ''; } if (!empty($lit['has_verifiable_fetch'])) { return true; } if (!empty($lit['from_bibliographic_fallback'])) { return false; } return $cleaned !== '' && $this->isSubstantiveLiteratureContent($cleaned); } /** * 无摘要/无外部文献内容时跳过 LLM 校对,标记为已完成并写明原因。 */ private function skipGroupNoLiterature(array $groupRows) { $reason = '未获取文献摘要或全文,已跳过自动相关性校对,请人工核对或补充文献内容后重跑。'; $now = date('Y-m-d H:i:s'); foreach ($groupRows as $gr) { $checkId = intval($gr['id'] ?? 0); if ($checkId <= 0) { continue; } $this->updateRow($checkId, [ 'is_relevant' => 0, 'relevance_score' => 0, 'reason' => $reason, 'author_comment' => '', 'combined_relevance_score' => 0, 'combined_reason' => $reason, 'claims_json' => '', 'status' => self::RECORD_COMPLETED, 'queue_status' => self::QUEUE_COMPLETED, 'error_msg' => '', 'updated_at' => $now, ]); } $this->markGroupQueueRuntime($groupRows, self::QUEUE_COMPLETED); $this->log('skip relevance check: no literature evidence, group_size=' . count($groupRows)); } private function markGroupNoLiteratureEvidence(array $groupRows) { foreach ($groupRows as $gr) { $checkId = intval($gr['id'] ?? 0); if ($checkId <= 0) { continue; } $this->updateRow($checkId, [ 'score_ceiling_trigger'=> 'no_literature_evidence', ]); } } /** * 仅使用 t_production_article_refer_literature 已入库摘要/清洗内容,不触发抓取。 */ private function buildCombinedStoredLiteratureText(array $groupRows) { $blocks = []; foreach ($groupRows as $gr) { $refNo = intval($gr['reference_no']); $text = $this->resolveLiteratureContentForGroupRow($gr, false); if ($refNo > 0 && $text !== '') { $blocks[] = '【参考文献 ' . $refNo . "】\n" . $text; } } return implode("\n\n", $blocks); } /** * 按 p_article_id + p_refer_id 从 t_production_article_refer_literature 取摘要/清洗内容; * 二者都为空且允许抓取时再外部获取并回写文献表。 */ private function resolveLiteratureContentForGroupRow(array $gr, $fetchIfMissing = true) { $pArticleId = intval($gr['p_article_id'] ?? 0); $pReferId = intval($gr['p_refer_id'] ?? 0); if ($pReferId <= 0) { return ''; } $lit = $this->ensureProductionReferLiterature( $pArticleId, $pReferId, $fetchIfMissing, trim((string)($gr['refer_text'] ?? '')) ); $checkId = intval($gr['id'] ?? 0); if ($checkId > 0 && ($lit['abstract'] !== '' || $lit['cleaned'] !== '')) { $this->updateRow($checkId, [ 'abstract_text' => $lit['abstract'], 'refer_content_cleaned' => $lit['cleaned'], ]); } if ($lit['abstract'] === '' && $lit['cleaned'] === '') { return ''; } return $this->formatLiteratureBlockForLlm($lit['abstract'], $lit['cleaned']); } /** * @return array{ * abstract:string, * cleaned:string, * from_bibliographic_fallback:bool, * has_verifiable_fetch:bool * } */ private function ensureProductionReferLiterature($pArticleId, $pReferId, $fetchIfMissing = true, $referTextFallback = '') { $empty = [ 'abstract' => '', 'cleaned' => '', 'from_bibliographic_fallback' => false, 'has_verifiable_fetch' => false, ]; $pArticleId = intval($pArticleId); $pReferId = intval($pReferId); if ($pReferId <= 0) { return $empty; } DbReconnectHelper::ensure(); $q = Db::name('production_article_refer')->where('p_refer_id', $pReferId); if ($pArticleId > 0) { $q->where('p_article_id', $pArticleId); } $refer = $q->find(); if (empty($refer)) { return $empty; } $litSvc = new ProductionArticleReferLiteratureService(); $storedLitRow = $litSvc->getByPReferId($pReferId, $pArticleId); $lit = $litSvc->loadForCheck($pReferId, $pArticleId); $abstract = trim((string)($lit['abstract_text'] ?? '')); $cleaned = trim((string)($lit['refer_content_cleaned'] ?? '')); $contentText = trim((string)($lit['content_text'] ?? '')); if ($abstract !== '' || $cleaned !== '' || $contentText !== '') { // 规则:同一 p_refer_id 只要已有缓存内容,直接复用,不再触发 refetch $substantive = $this->isSubstantiveLiteratureContent($abstract) || $this->isSubstantiveLiteratureContent($cleaned) || ($cleaned === '' && $this->isSubstantiveLiteratureContent($contentText)); $useCleaned = $cleaned !== '' ? $cleaned : $contentText; return [ 'abstract' => $abstract, 'cleaned' => $useCleaned, 'from_bibliographic_fallback' => ($abstract === '' && $useCleaned !== '' && !$substantive), 'has_verifiable_fetch' => $substantive, ]; } if ($fetchIfMissing && !empty($storedLitRow)) { $prevFetchLog = trim((string)($lit['fetch_log'] ?? '')); $prevUpdatedAt = trim((string)($storedLitRow['updated_at'] ?? '')); $prevTs = $prevUpdatedAt !== '' ? strtotime($prevUpdatedAt) : false; $isRecent = ($prevTs !== false) && ((time() - $prevTs) < 259200); // 72 小时内空抓取不重复打外部源 $wasEmptyFetch = (strpos($prevFetchLog, 'fetch_empty') !== false) || (strpos($prevFetchLog, 'no_doi_and_bibliographic_search_failed') !== false) || (strpos($prevFetchLog, 'refer_not_found') !== false); if ($wasEmptyFetch && $isRecent) { $this->log('literature fetch cooldown hit p_refer_id=' . $pReferId . ' log=' . $prevFetchLog); return $empty; } } if (!$fetchIfMissing) { return $empty; } DbReconnectHelper::release(); $bundle = (new ReferenceLiteratureFetchService())->fetchAndCleanForRefer($refer); DbReconnectHelper::ensure(); $abstract = trim((string)($bundle['abstract_final'] ?? '')); $raw = trim((string)($bundle['raw_content'] ?? '')); $cleaned = trim((string)($bundle['content_cleaned'] ?? '')); if ($cleaned === '' && $raw !== '') { $cleaned = mb_substr($raw, 0, 6000); } $hasVerifiableFetch = $this->hasVerifiableFetchFromBundle($bundle); $fromBibliographicFallback = false; $pdfUrl = trim((string)($bundle['pdf_url'] ?? '')); if ($abstract === '' && $cleaned === '') { $fallback = $this->buildReferBibliographicFallback($refer, $referTextFallback); if ($fallback !== '') { $fetchLog = trim((string)($bundle['fetch_log'] ?? 'fetch_empty')); $this->log('literature bibliographic fallback p_refer_id=' . $pReferId . ' log=' . $fetchLog); $cleaned = $fallback; $fromBibliographicFallback = true; $this->persistProductionReferLiterature($pArticleId, $pReferId, '', $cleaned, $pdfUrl, $bundle); return [ 'abstract' => '', 'cleaned' => $cleaned, 'from_bibliographic_fallback' => true, 'has_verifiable_fetch' => false, ]; } $bundle['fetch_log'] = trim((string)($bundle['fetch_log'] ?? 'fetch_empty')); $this->persistProductionReferLiterature($pArticleId, $pReferId, '', '', $pdfUrl, $bundle); $this->log('literature fetch empty p_refer_id=' . $pReferId . ' log=' . $bundle['fetch_log']); return $empty; } $this->persistProductionReferLiterature($pArticleId, $pReferId, $abstract, $cleaned, $pdfUrl, $bundle); return [ 'abstract' => $abstract, 'cleaned' => $cleaned, 'from_bibliographic_fallback' => $fromBibliographicFallback, 'has_verifiable_fetch' => $hasVerifiableFetch, ]; } private function hasVerifiableFetchFromBundle(array $bundle) { $sources = (array)($bundle['sources'] ?? []); $strongSources = ['cma_openalex', 'cma_yiigle', 'pmc_fulltext', 'unpaywall_pdf']; if (!empty(array_intersect($sources, $strongSources))) { return true; } $raw = (string)($bundle['raw_content'] ?? ''); if (in_array('europe_pmc', $sources, true) && $this->isSubstantiveLiteratureContent($raw)) { return true; } if (in_array('crossref', $sources, true) && preg_match('/Abstract:\s*(.{40,})/s', $raw)) { return true; } $abstract = trim((string)($bundle['abstract_final'] ?? $bundle['abstract'] ?? '')); return $abstract !== '' && $this->isSubstantiveLiteratureContent($abstract); } private function storedCleanedLooksFetched($cleaned) { return $this->isSubstantiveLiteratureContent($cleaned); } /** * 可校对证据:摘要/全文;排除 PubMed 仅 MeSH/书目元数据。 */ private function isSubstantiveLiteratureContent($text) { $text = trim((string)$text); if ($text === '') { return false; } if (preg_match('/(?:^|\n)Abstract:\s*(.+)/us', $text, $m)) { return mb_strlen(trim($m[1])) >= 40; } if (preg_match('/【摘要】\s*(.+)/us', $text, $m)) { return mb_strlen(trim($m[1])) >= 40; } if (preg_match('/===\s*(PMC Full Text|OA PDF|中华医学期刊全文)/u', $text)) { $body = preg_replace('/^===[^=]+===\s*\n?/us', '', $text); return mb_strlen(trim($body)) >= 200; } if (preg_match('/=== Europe PMC ===\s*\n(.+)/s', $text, $m)) { return mb_strlen(trim($m[1])) >= 200; } if (preg_match('/=== PubMed/u', $text)) { return false; } if (preg_match('/^(?:MeSH:|Publication Types:)/um', $text) && mb_strlen($text) < 500) { return false; } if (mb_strlen($text) >= 400 && !preg_match('/^MeSH:/um', $text)) { return true; } return false; } /** * 外部源(PMC/PubMed/Crossref/PDF)无摘要时,用参考书目信息兜底供 LLM 判断。 */ private function buildReferBibliographicFallback(array $refer, $referTextFallback = '') { $isBook = strtolower(trim((string)($refer['refer_type'] ?? ''))) === 'book' || trim((string)($refer['isbn'] ?? '')) !== ''; $blocks = []; if ($isBook) { foreach (['title', 'author', 'joura', 'dateno', 'isbn'] as $field) { $val = trim((string)($refer[$field] ?? '')); if ($val !== '') { $blocks[] = ucfirst($field) . ': ' . $val; } } } else { $formatted = $this->refUtil->formatReferForLlm($refer); if ($formatted !== '') { $blocks[] = $formatted; } foreach (['refer_frag', 'refer_content'] as $field) { $text = trim((string)($refer[$field] ?? '')); if ($text === '') { continue; } if ($field === 'refer_content' && $this->referSnippetLooksMismatched($refer, $text)) { continue; } if ($formatted !== '' && strpos($formatted, $text) !== false) { continue; } $blocks[] = $text; } } $referTextFallback = trim((string)$referTextFallback); if ($referTextFallback !== '' && !in_array($referTextFallback, $blocks, true) && !$this->referSnippetLooksMismatched($refer, $referTextFallback)) { $blocks[] = $referTextFallback; } $blocks = array_values(array_unique(array_filter($blocks))); if (empty($blocks)) { return ''; } $sourceText = implode("\n\n", $blocks); $bookMeta = $this->formatBookBibliographicMeta($sourceText, $refer); if ($bookMeta !== '') { array_unshift($blocks, $bookMeta); } return mb_substr(implode("\n\n", $blocks), 0, 6000); } /** * refer_content 等原始片段是否与 refer 行 title/author 明显不是同一文献。 */ private function referSnippetLooksMismatched(array $refer, $snippet) { $snippet = trim((string)$snippet); if ($snippet === '') { return false; } $fetchSvc = new ReferenceLiteratureFetchService(); return !$fetchSvc->storedContentMatchesRefer($refer, '', $snippet); } /** * 识别无 DOI 的图书/教材参考文献,生成结构化书目块供 LLM 判断。 */ private function formatBookBibliographicMeta($sourceText, array $refer = []) { $sourceText = trim((string)$sourceText); if ($sourceText === '') { return ''; } $hasIsbn = preg_match('/\bISBN[:\s]*([\d\-Xx]{10,17})/i', $sourceText, $isbnMatch); $hasEdition = preg_match('/\b\d+(?:st|nd|rd|th)\s+ed\.?/i', $sourceText); $hasPublisherYear = preg_match('/;\s*(19|20)\d{2}\s*\.?/i', $sourceText); $hasDoi = trim((string)($refer['refer_doi'] ?? '')) !== '' || trim((string)($refer['doilink'] ?? '')) !== '' || preg_match('/\b10\.\d{4,9}\//i', $sourceText); if (!$hasIsbn && !($hasEdition && $hasPublisherYear) && stripos($sourceText, 'ISBN') === false) { return ''; } if ($hasDoi && !$hasIsbn) { return ''; } $lines = [ '【文献类型】图书/教材(无 DOI、无外部摘要;请据书名、副标题、作者、出版社、版本、ISBN 判断类型与主题,不得仅因缺少摘要就给 0.25)', ]; $title = trim((string)($refer['title'] ?? '')); if ($title === '' && preg_match('/\.\s*([^.]+(?:Models|Theories|Knowledge|Nursing)[^.]*)\s*\./i', $sourceText, $m)) { $title = trim($m[1]); } if ($title !== '') { $lines[] = '【书名】' . $title; } $author = trim((string)($refer['author'] ?? '')); if ($author === '' && preg_match('/^([A-Z][A-Za-z\-]+(?:\s+[A-Z][A-Za-z\-]+)*(?:\s*,\s*[A-Z][A-Za-z\-]+(?:\s+[A-Z][A-Za-z\-]+)*)*)\./m', $sourceText, $m)) { $author = trim($m[1]); } if ($author !== '') { $lines[] = '【作者】' . $author; } $year = ''; if (preg_match('/;\s*((19|20)\d{2})/', $sourceText, $m)) { $year = $m[1]; } elseif (trim((string)($refer['dateno'] ?? '')) !== '') { $year = trim((string)$refer['dateno']); } $publisher = trim((string)($refer['joura'] ?? '')); if ($publisher !== '' || $year !== '') { $lines[] = '【出版信息】' . trim($publisher . ($publisher !== '' && $year !== '' ? '; ' : '') . $year); } if ($hasIsbn && !empty($isbnMatch[1])) { $lines[] = '【ISBN】' . trim($isbnMatch[1]); } return implode("\n", $lines); } private function persistProductionReferLiterature($pArticleId, $pReferId, $abstract, $cleaned, $pdfUrl = '', array $bundle = []) { $pArticleId = intval($pArticleId); $pReferId = intval($pReferId); if ($pReferId <= 0) { return; } $abstract = trim((string)$abstract); $cleaned = trim((string)$cleaned); $pdfUrl = trim((string)$pdfUrl); $contentText = trim((string)($bundle['raw_content'] ?? '')); if ($pdfUrl === '') { $pdfUrl = trim((string)($bundle['pdf_url'] ?? '')); } DbReconnectHelper::ensure(); $q = Db::name('production_article_refer')->where('p_refer_id', $pReferId); if ($pArticleId > 0) { $q->where('p_article_id', $pArticleId); } $refer = $q->find(); $referDoi = ''; if (!empty($refer)) { $referDoi = trim((string)($refer['refer_doi'] ?? '')); if ($referDoi === '') { $referDoi = trim((string)($refer['doilink'] ?? '')); } } if ($referDoi === '') { $referDoi = trim((string)($bundle['doi'] ?? '')); } (new ProductionArticleReferLiteratureService())->upsert($pArticleId, $pReferId, [ 'refer_doi' => $referDoi, 'abstract_text' => $abstract, 'content_text' => $contentText, 'mesh_terms' => $bundle['mesh_terms'] ?? [], 'refer_content_cleaned' => $cleaned, 'literature_pdf_url' => $pdfUrl, 'fetch_sources' => $bundle['sources'] ?? [], 'fetch_log' => $bundle['fetch_log'] ?? '', ]); } private function formatLiteratureBlockForLlm($abstract, $cleaned) { $parts = []; if (trim((string)$abstract) !== '') { $parts[] = "【摘要】\n" . trim((string)$abstract); } if (trim((string)$cleaned) !== '') { $label = trim((string)$abstract) === '' ? '【文献书目信息(无外部摘要,据参考书目判断)】' : '【清洗后文献内容】'; $parts[] = $label . "\n" . trim((string)$cleaned); } return implode("\n\n", $parts); } /** * @deprecated 使用 buildCombinedLiteratureText */ private function buildCombinedAbstractText(array $groupRows) { return $this->buildCombinedLiteratureText($groupRows); } /** * @deprecated 使用 resolveLiteratureContentForGroupRow */ private function resolveAbstractTextForGroupRow(array $gr) { return $this->resolveLiteratureContentForGroupRow($gr); } private function buildCombinedReferText(array $groupRows) { $blocks = []; foreach ($groupRows as $gr) { $refNo = intval($gr['reference_no']); $text = trim((string)$gr['refer_text']); if ($refNo > 0 && $text !== '') { $blocks[] = '【参考文献 ' . $refNo . "】\n" . $text; } } return implode("\n\n", $blocks); } /** * 解析引用组内每条文献的类型(图书/期刊/其他),供 LLM 分轨校对。 * * @return array reference_no => 类型信息 */ private function resolveReferTypeMap(array $groupRows) { $pReferIds = []; foreach ($groupRows as $gr) { $pReferId = intval($gr['p_refer_id'] ?? 0); if ($pReferId > 0) { $pReferIds[$pReferId] = $pReferId; } } $referById = []; 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') ->whereIn('p_refer_id', array_values($pReferIds)) ->select(); foreach ($rows as $r) { $referById[intval($r['p_refer_id'])] = $r; } } $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'] ?? ''))); $map[$refNo] = [ 'type' => $type, 'check_mode' => $type === 'book' ? 'bibliographic_inference' : ($type === 'journal' ? 'abstract_verification' : 'best_effort'), ]; } return $map; } /** * 归一化文献类型:优先取 refer_type 字段,其次按 ISBN/DOI 规则兜底。 */ private function normalizeReferType(array $refer, $referTextFallback = '') { $type = strtolower(trim((string)($refer['refer_type'] ?? ''))); if ($type === 'book' || $type === 'journal') { return $type; } $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'; } if ($hasDoi || preg_match('/\b10\.\d{4,9}\//', $sourceText)) { return 'journal'; } return 'other'; } private function applyGroupResults(array $groupRows, array $llmResponse) { $results = isset($llmResponse['results']) && is_array($llmResponse['results']) ? $llmResponse['results'] : []; if (empty($results)) { return false; } $combinedScore = floatval(isset($llmResponse['combined_relevance_score']) ? $llmResponse['combined_relevance_score'] : 0); $combinedReason = trim((string)(isset($llmResponse['combined_reason']) ? $llmResponse['combined_reason'] : '')); $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; } } $expected = 0; $applied = 0; foreach ($groupRows as $gr) { $refNo = intval($gr['reference_no']); if ($refNo <= 0) { continue; } $expected++; if (!isset($byRef[$refNo])) { 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']); $this->updateRow(intval($gr['id']), [ '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, 'claims_json' => $claimsJson, 'status' => self::RECORD_COMPLETED, 'error_msg' => '', ]); $applied++; } return $expected > 0 && $applied === $expected; } private function failGroup(array $groupRows, $msg) { $msg = mb_substr(trim((string)$msg), 0, 512); foreach ($groupRows as $gr) { $this->updateRow(intval($gr['id']), [ 'status' => self::RECORD_FAILED, 'error_msg' => $msg, ]); } } private function updateRow($checkId, array $fields) { DbReconnectHelper::ensure(); if (isset($fields['reason'])) { $fields['reason'] = mb_substr(trim((string)$fields['reason']), 0, 2000); } if (isset($fields['author_comment'])) { $fields['author_comment'] = mb_substr(trim((string)$fields['author_comment']), 0, 2000); } if (isset($fields['combined_reason'])) { $fields['combined_reason'] = mb_substr(trim((string)$fields['combined_reason']), 0, 2000); } if (isset($fields['claims_json'])) { $fields['claims_json'] = mb_substr(trim((string)$fields['claims_json']), 0, 4000); } $fields['updated_at'] = date('Y-m-d H:i:s'); return Db::name('article_reference_relevance_check_result') ->where('id', intval($checkId)) ->update($fields); } private function relevanceCheckResultResetFields(array $extra = []) { return array_merge([ 'status' => self::RECORD_PENDING, 'queue_status' => self::QUEUE_PENDING, 'retry_count' => 0, 'is_relevant' => 0, 'relevance_score' => 0, 'reason' => '', 'author_comment' => '', 'combined_relevance_score' => 0, 'combined_reason' => '', 'claims_json' => '', 'score_ceiling_trigger' => '', 'error_msg' => '', ], $extra); } /** * 引用处局部上下文:优先相关性专用方法,线上旧版 ReferenceCheckService 回退到支撑力度同款方法。 */ private function resolveLocalContextForJob(array $row) { if (method_exists($this->refUtil, 'resolveCitationLocalContextForRelevanceJob')) { return $this->refUtil->resolveCitationLocalContextForRelevanceJob($row); } if (method_exists($this->refUtil, 'resolveCitationLocalContextForJob')) { return $this->refUtil->resolveCitationLocalContextForJob($row); } return trim((string)(isset($row['origin_text']) ? $row['origin_text'] : '')); } private function formatReturnFromRow(array $row) { $claims = $this->decodeClaimsJson(isset($row['claims_json']) ? $row['claims_json'] : ''); $reason = (string)$row['reason']; if (!empty($claims)) { $parts = []; foreach ($claims as $k => $v) { $key = trim((string)$k); if (is_array($v)) { $val = trim((string)json_encode($v, JSON_UNESCAPED_UNICODE)); } else { $val = trim((string)$v); } if ($val === '') { continue; } $parts[] = ($key !== '' ? ($key . ':') : '') . $val; } if (!empty($parts)) { $claimsText = mb_substr(implode(';', $parts), 0, 2000); $reason = $claimsText . "\n" . $reason; } } $author_comment = $this->resolveAuthorCommentFromRow($row, $claims); if($author_comment){ $reason = $reason . "\n" . $author_comment; } return [ 'check_id' => intval($row['id']), 'p_refer_id' => intval($row['p_refer_id']), 'reference_no' => intval($row['reference_no']), 'am_id' => intval($row['am_id']), 'status' => intval($row['status']), 'is_relevant' => intval($row['is_relevant']), 'relevance_score' => floatval($row['relevance_score']), 'reason' => $reason, 'author_comment' => $author_comment, 'combined_relevance_score' => floatval($row['combined_relevance_score']), 'combined_reason' => (string)$row['combined_reason'], 'cite_group_refs' => (string)$row['cite_group_refs'], 'claims' => $claims, 'evidence_mode' => ((string)($row['score_ceiling_trigger'] ?? '') === 'no_literature_evidence') ? 'bibliographic_only' : 'literature_evidence', 'has_literature_evidence' => ((string)($row['score_ceiling_trigger'] ?? '') !== 'no_literature_evidence'), 'origin_text' => (string)$row['origin_text'], ]; } /** * @param array|string $claims */ private function encodeClaimsJson($claims) { if (is_string($claims)) { $claims = trim($claims); if ($claims === '') { return ''; } $decoded = json_decode($claims, true); if (is_array($decoded) && !empty($decoded)) { return mb_substr(json_encode($decoded, JSON_UNESCAPED_UNICODE), 0, 4000); } return mb_substr($claims, 0, 4000); } if (!is_array($claims) || empty($claims)) { return ''; } return mb_substr(json_encode($claims, JSON_UNESCAPED_UNICODE), 0, 4000); } private function decodeClaimsJson($json) { $json = trim((string)$json); if ($json === '') { return []; } $decoded = json_decode($json, true); return is_array($decoded) ? $decoded : []; } private function resolveAuthorCommentFromRow(array $row, array $claims = []) { $stored = trim((string)($row['author_comment'] ?? '')); if ($stored !== '') { return $stored; } return $this->buildAuthorCommentForDisplay( floatval($row['relevance_score'] ?? 0), (string)($row['reason'] ?? ''), $claims ); } private function buildAuthorCommentForDisplay($score, $reason, array $claims = []) { $score = floatval($score); if ($score > 0.65 + 0.001) { return ''; } if (!empty($claims)) { $targets = []; foreach ($claims as $txt) { $txt = trim((string)$txt); if ($txt !== '') { $targets[] = $txt; } if (count($targets) >= 2) { break; } } if (!empty($targets)) { return '该处正文主要涉及“' . implode('”“', $targets) . '”等表述,现有文献侧重点似乎略有不同。建议替换相应编号文献以更直接支持上述表述,或酌情调整该句,使引用内容与文献证据保持一致。'; } } $reason = trim((string)$reason); if ($reason === '') { return '该处参考文献与正文表述的对应关系似乎尚不够充分,文献侧重点与正文核心论点略有不同。建议替换相应编号文献以更直接支持此处表述,或酌情调整该句,使引用内容与文献证据保持一致。'; } $reason = preg_replace('/Claim覆盖[::][^。;;\n]*/u', '', $reason); $reason = preg_replace('/\b[A-E]\s*(?:[✔✘]|部分)\b/u', '', $reason); $reason = preg_replace('/[✔✘]/u', '', $reason); $reason = preg_replace('/\b0?\.\d{1,2}\b/u', '', $reason); $reason = preg_replace('/故\s*(?:联合分?)?\s*[01](?:\.\d+)?[。.]?/u', '', $reason); $reason = preg_replace('/(?:建议|请|可|需|应|还|另|再)?\s*补充(?:一篇|一条|相关)?(?:参考文献|文献)?/u', '建议替换现有编号文献', $reason); $reason = preg_replace('/补充文献/u', '替换文献', $reason); $reason = preg_replace('/(?:新增|增加)\s*(?:一篇|一条)?(?:参考文献|文献)/u', '替换现有编号文献', $reason); $reason = trim((string)$reason); if ($reason === '') { return '该处参考文献与正文表述的对应关系似乎尚不够充分,文献侧重点与正文核心论点略有不同。建议替换相应编号文献以更直接支持此处表述,或酌情调整该句,使引用内容与文献证据保持一致。'; } $text = mb_substr(trim($reason), 0, 160); $text = trim($text, " \t\n\r\0\x0B;;,,。"); return $text === '' ? '' : ($text . '。'); } /** * 按 p_article_id 清空整篇文章的引用校对明细 + 重置节级 ref_check_status。 * * 用于新增/删除文献后,旧的 reference_no 全部错位、原校对结果失效的场景: * 物理删除后,整篇状态查询自然回到 ARTICLE_PROGRESS_NONE(未校对)。 * * @return int 被删除的明细条数 */ public function clearArticleChecksByPArticleId($pArticleId,$articleId=0) { $pArticleId = intval($pArticleId); if ($pArticleId <= 0) { return 0; } // 先反查 article_id(用于重置 article_main.ref_check_status 节级状态) if($articleId==0){ $articleId = intval(Db::name('production_article') ->where('p_article_id', $pArticleId) ->whereIn('state', [0, 2]) ->value('article_id')); } $deleted = Db::name('article_reference_relevance_check_result') ->where('p_article_id', $pArticleId) ->delete(); if ($articleId > 0) { Db::name('article_main') ->where('article_id', $articleId) ->whereIn('state', [0, 2]) ->update(['ref_check_status' => self::ARTICLE_PROGRESS_NONE]); } return intval($deleted); } }