Files
tougao/application/common/ReferenceRelevanceCheckService.php

3110 lines
118 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common;
use think\Db;
use app\common\mq\ReferenceCheckMqPublisher;
use app\common\service\ReferenceRelevanceLlmService;
/**
* 参考文献「主题相关性」校对(独立于 ReferenceCheckService 支撑力度校对)
* 异步RabbitMQ 文章批次链式消费(与 reference_check 相同模式)
*/
class ReferenceRelevanceCheckService
{
const TRANSPORT_RABBITMQ = 'rabbitmq';
const RECORD_PENDING = 0;
const RECORD_COMPLETED = 2;
const RECORD_FAILED = 3;
const QUEUE_PENDING = 0;
const QUEUE_RUNNING = 1;
const QUEUE_COMPLETED = 2;
const QUEUE_FAILED = 3;
const QUEUE_MAX_RETRY = 0;
const PASS_SCORE_THRESHOLD = 0.65;
/** 整篇文章的相关性校对状态(与 ReferenceCheckService::ARTICLE_PROGRESS_* 语义一致) */
const ARTICLE_PROGRESS_NONE = 0;
const ARTICLE_PROGRESS_RUNNING = 1;
const ARTICLE_PROGRESS_COMPLETED = 2;
/** 是否在逐条校对前全量预抓取文献摘要/清洗内容true 时 Worker 先写 refer 表再校对 */
const PREPARE_LITERATURE_BEFORE_CHECK = false;
/** @var ReferenceCheckService */
private $refUtil;
private $logFile;
public function __construct()
{
$this->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);
}
// 组长已完成时,允许任一 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->buildCiteGroupRefsFromRows($pendingGroupRows);
$referText = $this->buildCombinedReferText($pendingGroupRows);
$referTypeMap = $this->resolveReferTypeMap($pendingGroupRows);
if ($skipLiteratureFetch) {
$literatureBundle = $this->resolveGroupLiteratureBundle($pendingGroupRows, $referTypeMap, false);
$abstractText = $literatureBundle['combined_text'];
} else {
DbReconnectHelper::release();
$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($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 = $llmService->checkRelevance(
$sectionText,
$localContext,
$referText,
$abstractText,
$citeGroupRefs,
$referTypeMap,
$onChunkDone
);
DbReconnectHelper::ensure();
// 回调未覆盖的结果(单次调用或回调异常时)再补写一次
$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';
// 逐篇/分块:单次 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) {
$completedRows = [];
foreach ($this->findCitationGroupRows($row) as $gr) {
if (intval($gr['status']) === self::RECORD_COMPLETED) {
$completedRows[] = $gr;
}
}
if (!empty($completedRows)) {
$this->markGroupNoLiteratureEvidence($completedRows);
}
}
// 仍未完成的编号保持 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);
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'] : '');
$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 ? ("<b>[文献 " . intval($row['reference_no']) . "]: </b>" . $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'],
'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')
? '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,
]);
// 默认仅回收“长时间未更新”的 RUNNINGforce 时立刻回退,从卡死条继续
$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,
'pub_language' => $bundle['language'] ?? '',
'pub_country' => $bundle['country'] ?? '',
'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);
}
/**
* 联合引用组:仅组长(最小 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']);
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,
'combined_author_comment' => (new ReferenceRelevanceLlmService())->buildCombinedAuthorCommentFromReason(0, $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'] ?? [],
'pub_language' => $bundle['language'] ?? '',
'pub_country' => $bundle['country'] ?? '',
'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<int,array{type:string,check_mode:string}> 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,joura,dateno')
->whereIn('p_refer_id', array_values($pReferIds))
->select();
foreach ($rows as $r) {
$referById[intval($r['p_refer_id'])] = $r;
}
}
$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;
}
$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'),
'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'] ?? '')));
$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'] ?? '')) !== '';
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'] : ''));
$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;
}
}
$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']);
$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']),
'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' => '',
]);
$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['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);
}
$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' => '',
'combined_author_comment' => '',
'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);
$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']),
'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'],
'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')
? 'bibliographic_only'
: 'literature_evidence',
'has_literature_evidence' => ((string)($row['score_ceiling_trigger'] ?? '') !== 'no_literature_evidence'),
'origin_text' => (string)$row['origin_text'],
];
}
/**
* 联合分 <= 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
*/
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);
}
}