2492 lines
92 KiB
PHP
2492 lines
92 KiB
PHP
<?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);
|
||
}
|
||
}
|