Files
tougao/application/common/ReferenceRelevanceCheckService.php
2026-07-16 17:02:51 +08:00

2492 lines
92 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);
}
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'] : '');
$g['records'][] = [
'check_id' => intval($row['id']),
'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' => $this->resolveAuthorCommentFromRow($row, $claims),
'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 回退为待执行
*/
public function recoverQueueRowsForArticle($pArticleId)
{
$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避免多消费者并发时把正在执行的任务误回退成 PENDING
$runningStaleBefore = 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_PENDING)
->where('updated_at', '<', $runningStaleBefore)
->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<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')
->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 = trim((string)$reason);
if ($reason === '') {
return '该条文献对当前表述支撑较弱,建议补充更直接覆盖核心论点的参考文献。';
}
return mb_substr('建议:' . $reason, 0, 120);
}
/**
* 按 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 && $this->hasAmRefCheckStatusColumn()) {
Db::name('article_main')
->where('article_id', $articleId)
->whereIn('state', [0, 2])
->update(['ref_check_status' => self::AM_STATUS_NONE]);
}
return intval($deleted);
}
}